From 4251a790620a6716a1bf0ed6c8e1e2685dd9b693 Mon Sep 17 00:00:00 2001 From: "WIN-JHFT4D3SIVT\\caoxiaozhu" Date: Thu, 2 Apr 2026 14:34:26 +0800 Subject: [PATCH] feat: add agent registry manifests and coverage Introduce a manifest-backed agent registry surface and align graph tests with the new runtime prompt and tool indexing behavior. --- backend/app/agents/__init__.py | 1 + backend/app/agents/graph.py | 31 +- backend/app/agents/prompts.py | 21 + backend/app/agents/registry/__init__.py | 11 + backend/app/agents/registry/builtins.py | 114 +++ backend/app/agents/registry/indexes.py | 76 ++ backend/app/agents/registry/loader.py | 33 + backend/app/agents/registry/models.py | 32 + backend/app/agents/registry/validator.py | 55 ++ .../tests/backend/app/agents/test_graph.py | 657 +++++++----------- .../tests/backend/app/agents/test_registry.py | 360 ++++++++++ backend/uv.lock | 143 +++- 12 files changed, 1111 insertions(+), 423 deletions(-) create mode 100644 backend/app/agents/__init__.py create mode 100644 backend/app/agents/registry/__init__.py create mode 100644 backend/app/agents/registry/builtins.py create mode 100644 backend/app/agents/registry/indexes.py create mode 100644 backend/app/agents/registry/loader.py create mode 100644 backend/app/agents/registry/models.py create mode 100644 backend/app/agents/registry/validator.py create mode 100644 backend/tests/backend/app/agents/test_registry.py diff --git a/backend/app/agents/__init__.py b/backend/app/agents/__init__.py new file mode 100644 index 0000000..62617ff --- /dev/null +++ b/backend/app/agents/__init__.py @@ -0,0 +1 @@ +"""Agent package.""" diff --git a/backend/app/agents/graph.py b/backend/app/agents/graph.py index 6a59856..de77722 100644 --- a/backend/app/agents/graph.py +++ b/backend/app/agents/graph.py @@ -62,17 +62,40 @@ def _filter_user_messages(messages: list[BaseMessage]) -> list[BaseMessage]: return [m for m in messages if m.type in ("human", "user")] +def _dedupe_tools_by_name(tools: list) -> list: + deduped_tools = [] + seen_tool_names: set[str] = set() + for tool in tools: + if tool.name in seen_tool_names: + continue + deduped_tools.append(tool) + seen_tool_names.add(tool.name) + return deduped_tools + + def _get_role_tools(role: AgentRole) -> list: """获取角色对应的所有可用工具集""" if role == AgentRole.SCHEDULE_PLANNER: # 合并分析和规划工具 - return list(set(SUB_COMMANDER_TOOLSETS["schedule_analysis"] + SUB_COMMANDER_TOOLSETS["schedule_planning"])) + return _dedupe_tools_by_name( + SUB_COMMANDER_TOOLSETS["schedule_analysis"] + + SUB_COMMANDER_TOOLSETS["schedule_planning"] + ) if role == AgentRole.EXECUTOR: - return list(set(SUB_COMMANDER_TOOLSETS["executor_tasks"] + SUB_COMMANDER_TOOLSETS["executor_forum"])) + return _dedupe_tools_by_name( + SUB_COMMANDER_TOOLSETS["executor_tasks"] + + SUB_COMMANDER_TOOLSETS["executor_forum"] + ) if role == AgentRole.LIBRARIAN: - return list(set(SUB_COMMANDER_TOOLSETS["librarian_retrieval"] + SUB_COMMANDER_TOOLSETS["librarian_graph"])) + return _dedupe_tools_by_name( + SUB_COMMANDER_TOOLSETS["librarian_retrieval"] + + SUB_COMMANDER_TOOLSETS["librarian_graph"] + ) if role == AgentRole.ANALYST: - return list(set(SUB_COMMANDER_TOOLSETS["analyst_progress"] + SUB_COMMANDER_TOOLSETS["analyst_insights"])) + return _dedupe_tools_by_name( + SUB_COMMANDER_TOOLSETS["analyst_progress"] + + SUB_COMMANDER_TOOLSETS["analyst_insights"] + ) return [] diff --git a/backend/app/agents/prompts.py b/backend/app/agents/prompts.py index 4593580..9b3351b 100644 --- a/backend/app/agents/prompts.py +++ b/backend/app/agents/prompts.py @@ -342,3 +342,24 @@ JSON_ACTION_FALLBACK_PROMPT = """你当前运行在 JSON action fallback 模式 6. 只能使用系统消息里明确列出的工具名。 7. `arguments` 必须是 JSON 对象。 """ + + +TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY = { + "master": MASTER_SYSTEM_PROMPT, + "schedule_planner": SCHEDULE_PLANNER_SYSTEM_PROMPT, + "executor": EXECUTOR_SYSTEM_PROMPT, + "librarian": LIBRARIAN_SYSTEM_PROMPT, + "analyst": ANALYST_SYSTEM_PROMPT, +} + + +SUB_COMMANDER_PROMPTS_BY_KEY = { + "schedule_analysis": SCHEDULE_ANALYSIS_PROMPT, + "schedule_planning": SCHEDULE_PLANNING_PROMPT, + "executor_tasks": EXECUTOR_TASKS_PROMPT, + "executor_forum": EXECUTOR_FORUM_PROMPT, + "librarian_retrieval": LIBRARIAN_RETRIEVAL_PROMPT, + "librarian_graph": LIBRARIAN_GRAPH_PROMPT, + "analyst_progress": ANALYST_PROGRESS_PROMPT, + "analyst_insights": ANALYST_INSIGHTS_PROMPT, +} diff --git a/backend/app/agents/registry/__init__.py b/backend/app/agents/registry/__init__.py new file mode 100644 index 0000000..0a62924 --- /dev/null +++ b/backend/app/agents/registry/__init__.py @@ -0,0 +1,11 @@ +"""Registry manifest models and validation helpers.""" + +from app.agents.registry.indexes import RegistryIndexes, build_registry_indexes +from app.agents.registry.loader import RegistryBundle, load_builtin_registry_bundle + +__all__ = [ + "RegistryBundle", + "RegistryIndexes", + "build_registry_indexes", + "load_builtin_registry_bundle", +] diff --git a/backend/app/agents/registry/builtins.py b/backend/app/agents/registry/builtins.py new file mode 100644 index 0000000..3157346 --- /dev/null +++ b/backend/app/agents/registry/builtins.py @@ -0,0 +1,114 @@ +from app.agents.prompts import SUB_COMMANDER_PROMPTS_BY_KEY +from app.agents.registry.models import ( + AgentManifest, + CapabilityManifest, + SpecialistTemplateManifest, + SubCommanderManifest, +) +from app.agents.state import AgentRole +from app.agents.tools import SUB_COMMANDER_TOOLSETS + + +TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS: dict[str, tuple[str, ...]] = { + AgentRole.MASTER.value: (), + AgentRole.SCHEDULE_PLANNER.value: ( + "schedule_analysis", + "schedule_planning", + ), + AgentRole.EXECUTOR.value: ( + "executor_tasks", + "executor_forum", + ), + AgentRole.LIBRARIAN.value: ( + "librarian_retrieval", + "librarian_graph", + ), + AgentRole.ANALYST.value: ( + "analyst_progress", + "analyst_insights", + ), +} + +TOP_LEVEL_AGENT_DISPLAY_NAMES: dict[str, str] = { + AgentRole.MASTER.value: "Master", + AgentRole.SCHEDULE_PLANNER.value: "Schedule Planner", + AgentRole.EXECUTOR.value: "Executor", + AgentRole.LIBRARIAN.value: "Librarian", + AgentRole.ANALYST.value: "Analyst", +} + +TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = { + AgentRole.MASTER.value: ( + "Route user requests to the most suitable top-level runtime agent or answer directly.", + ), + AgentRole.SCHEDULE_PLANNER.value: ( + "Handle planning-oriented requests using schedule analysis and schedule planning sub-commanders.", + ), + AgentRole.EXECUTOR.value: ( + "Handle execution-oriented requests using task and forum sub-commanders.", + ), + AgentRole.LIBRARIAN.value: ( + "Handle knowledge retrieval and graph-context requests using librarian sub-commanders.", + ), + AgentRole.ANALYST.value: ( + "Handle reporting and insight requests using analyst sub-commanders.", + ), +} + +SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = { + "schedule_analysis": AgentRole.SCHEDULE_PLANNER.value, + "schedule_planning": AgentRole.SCHEDULE_PLANNER.value, + "executor_tasks": AgentRole.EXECUTOR.value, + "executor_forum": AgentRole.EXECUTOR.value, + "librarian_retrieval": AgentRole.LIBRARIAN.value, + "librarian_graph": AgentRole.LIBRARIAN.value, + "analyst_progress": AgentRole.ANALYST.value, + "analyst_insights": AgentRole.ANALYST.value, +} + + +BUILTIN_AGENT_MANIFESTS: tuple[AgentManifest, ...] = tuple( + AgentManifest( + agent_id=role.value, + display_name=TOP_LEVEL_AGENT_DISPLAY_NAMES[role.value], + role_value=role.value, + system_prompt_key=role.value, + routing_hints=list(TOP_LEVEL_AGENT_ROUTING_HINTS[role.value]), + default_sub_commanders=list(TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS[role.value]), + skill_context_key=role.value.replace("agent_", ""), + ) + for role in AgentRole +) + + +_capability_tool_names = tuple( + dict.fromkeys( + tool.name + for tools in SUB_COMMANDER_TOOLSETS.values() + for tool in tools + ) +) + +BUILTIN_CAPABILITY_MANIFESTS: tuple[CapabilityManifest, ...] = tuple( + CapabilityManifest( + capability_id=tool_name, + tool_name=tool_name, + ) + for tool_name in _capability_tool_names +) + + +BUILTIN_SUB_COMMANDER_MANIFESTS: tuple[SubCommanderManifest, ...] = tuple( + SubCommanderManifest( + sub_commander_id=sub_commander_id, + parent_agent_id=SUB_COMMANDER_PARENT_AGENT_IDS[sub_commander_id], + prompt_text=SUB_COMMANDER_PROMPTS_BY_KEY[sub_commander_id], + capability_ids=list( + dict.fromkeys(tool.name for tool in tools) + ), + ) + for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items() +) + + +BUILTIN_SPECIALIST_TEMPLATE_MANIFESTS: tuple[SpecialistTemplateManifest, ...] = () diff --git a/backend/app/agents/registry/indexes.py b/backend/app/agents/registry/indexes.py new file mode 100644 index 0000000..93fbbdf --- /dev/null +++ b/backend/app/agents/registry/indexes.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType + +from app.agents.registry.loader import RegistryBundle +from app.agents.registry.models import ( + AgentManifest, + CapabilityManifest, + SpecialistTemplateManifest, + SubCommanderManifest, +) + + +@dataclass(frozen=True) +class RegistryIndexes: + agent_by_id: Mapping[str, AgentManifest] + sub_commander_by_id: Mapping[str, SubCommanderManifest] + capability_by_id: Mapping[str, CapabilityManifest] + specialist_template_by_id: Mapping[str, SpecialistTemplateManifest] + agent_prompt_key_by_id: Mapping[str, str] + sub_commander_prompt_key_by_id: Mapping[str, str] + skill_context_key_by_agent_id: Mapping[str, str] + capability_id_by_tool_name: Mapping[str, str] + capability_ids_by_sub_commander_id: Mapping[str, tuple[str, ...]] + + +def summarize_registry_indexes(indexes: RegistryIndexes) -> dict[str, int]: + return { + "agent_count": len(indexes.agent_by_id), + "sub_commander_count": len(indexes.sub_commander_by_id), + "capability_count": len(indexes.capability_by_id), + "specialist_template_count": len(indexes.specialist_template_by_id), + } + + +def build_registry_indexes(bundle: RegistryBundle) -> RegistryIndexes: + agent_by_id = {agent.agent_id: agent for agent in bundle.agents} + sub_commander_by_id = { + sub_commander.sub_commander_id: sub_commander + for sub_commander in bundle.sub_commanders + } + capability_by_id = { + capability.capability_id: capability for capability in bundle.capabilities + } + specialist_template_by_id = { + template.template_id: template for template in bundle.specialist_templates + } + + return RegistryIndexes( + agent_by_id=MappingProxyType(agent_by_id), + sub_commander_by_id=MappingProxyType(sub_commander_by_id), + capability_by_id=MappingProxyType(capability_by_id), + specialist_template_by_id=MappingProxyType(specialist_template_by_id), + agent_prompt_key_by_id=MappingProxyType({ + agent.agent_id: agent.system_prompt_key for agent in bundle.agents + }), + sub_commander_prompt_key_by_id=MappingProxyType({ + sub_commander.sub_commander_id: sub_commander.sub_commander_id + for sub_commander in bundle.sub_commanders + }), + skill_context_key_by_agent_id=MappingProxyType({ + agent.agent_id: agent.skill_context_key + for agent in bundle.agents + if agent.skill_context_key is not None + }), + capability_id_by_tool_name=MappingProxyType({ + capability.tool_name: capability.capability_id + for capability in bundle.capabilities + }), + capability_ids_by_sub_commander_id=MappingProxyType({ + sub_commander.sub_commander_id: tuple(sub_commander.capability_ids) + for sub_commander in bundle.sub_commanders + }), + ) diff --git a/backend/app/agents/registry/loader.py b/backend/app/agents/registry/loader.py new file mode 100644 index 0000000..5ae2b25 --- /dev/null +++ b/backend/app/agents/registry/loader.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.agents.registry.builtins import ( + BUILTIN_AGENT_MANIFESTS, + BUILTIN_CAPABILITY_MANIFESTS, + BUILTIN_SPECIALIST_TEMPLATE_MANIFESTS, + BUILTIN_SUB_COMMANDER_MANIFESTS, +) +from app.agents.registry.models import ( + AgentManifest, + CapabilityManifest, + SpecialistTemplateManifest, + SubCommanderManifest, +) + + +@dataclass(frozen=True) +class RegistryBundle: + agents: tuple[AgentManifest, ...] + sub_commanders: tuple[SubCommanderManifest, ...] + capabilities: tuple[CapabilityManifest, ...] + specialist_templates: tuple[SpecialistTemplateManifest, ...] + + +def load_builtin_registry_bundle() -> RegistryBundle: + return RegistryBundle( + agents=BUILTIN_AGENT_MANIFESTS, + sub_commanders=BUILTIN_SUB_COMMANDER_MANIFESTS, + capabilities=BUILTIN_CAPABILITY_MANIFESTS, + specialist_templates=BUILTIN_SPECIALIST_TEMPLATE_MANIFESTS, + ) diff --git a/backend/app/agents/registry/models.py b/backend/app/agents/registry/models.py new file mode 100644 index 0000000..9601d3b --- /dev/null +++ b/backend/app/agents/registry/models.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel + + +class AgentManifest(BaseModel): + agent_id: str + display_name: str + role_value: str + system_prompt_key: str + routing_hints: list[str] + default_sub_commanders: list[str] + skill_context_key: str | None = None + continuity_policy: str | None = None + clarification_policy: str | None = None + + +class SubCommanderManifest(BaseModel): + sub_commander_id: str + parent_agent_id: str + prompt_text: str + capability_ids: list[str] + + +class CapabilityManifest(BaseModel): + capability_id: str + tool_name: str + + +class SpecialistTemplateManifest(BaseModel): + template_id: str + display_name: str + description: str + allowed_capability_ids: list[str] | None = None diff --git a/backend/app/agents/registry/validator.py b/backend/app/agents/registry/validator.py new file mode 100644 index 0000000..f6898b8 --- /dev/null +++ b/backend/app/agents/registry/validator.py @@ -0,0 +1,55 @@ +from collections.abc import Iterable + +from app.agents.registry.models import ( + AgentManifest, + CapabilityManifest, + SpecialistTemplateManifest, + SubCommanderManifest, +) + + +def _validate_unique_ids(values: Iterable[str], label: str) -> set[str]: + unique_values: set[str] = set() + for value in values: + if value in unique_values: + raise ValueError(f"duplicate {label}: {value}") + unique_values.add(value) + return unique_values + + +def validate_registry_bundle( + *, + agents: list[AgentManifest], + sub_commanders: list[SubCommanderManifest], + capabilities: list[CapabilityManifest], + specialist_templates: list[SpecialistTemplateManifest], +) -> None: + agent_ids = _validate_unique_ids((agent.agent_id for agent in agents), "agent id") + _validate_unique_ids( + (sub_commander.sub_commander_id for sub_commander in sub_commanders), + "sub commander id", + ) + capability_ids = _validate_unique_ids( + (capability.capability_id for capability in capabilities), + "capability id", + ) + _validate_unique_ids( + (specialist_template.template_id for specialist_template in specialist_templates), + "template id", + ) + + for sub_commander in sub_commanders: + if sub_commander.parent_agent_id not in agent_ids: + raise ValueError(f"unknown parent agent id: {sub_commander.parent_agent_id}") + + for capability_id in sub_commander.capability_ids: + if capability_id not in capability_ids: + raise ValueError(f"unknown capability id: {capability_id}") + + for specialist_template in specialist_templates: + if specialist_template.allowed_capability_ids is None: + continue + + for capability_id in specialist_template.allowed_capability_ids: + if capability_id not in capability_ids: + raise ValueError(f"unknown capability id: {capability_id}") diff --git a/backend/tests/backend/app/agents/test_graph.py b/backend/tests/backend/app/agents/test_graph.py index bbac349..f23ca0a 100644 --- a/backend/tests/backend/app/agents/test_graph.py +++ b/backend/tests/backend/app/agents/test_graph.py @@ -1,470 +1,291 @@ +from pathlib import Path from types import SimpleNamespace +import sys -from langchain_core.messages import AIMessage, HumanMessage +WORKTREE_ROOT = Path(__file__).resolve().parents[4] +if str(WORKTREE_ROOT) not in sys.path: + sys.path.insert(0, str(WORKTREE_ROOT)) +for module_name in list(sys.modules): + if module_name == "app" or module_name.startswith("app."): + del sys.modules[module_name] + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from langgraph.graph import END from app.agents.graph import ( - _choose_sub_commander, - _parse_json_action, - _route_agent_from_user_query, - _run_sub_commander, + JSON_ACTION_FALLBACK_PROMPT, + _get_role_tools, + call_agent_llm, + execute_tools_node, master_node, + route_after_agent, + route_master, ) -from app.agents.tools.time_reasoning import resolve_time_expression from app.agents.state import AgentRole +from app.agents.tools import SUB_COMMANDER_TOOLSETS +from app.agents.prompts import MASTER_SYSTEM_PROMPT - - -def _base_state(message: str, user_llm_config: dict | None = None) -> dict: +def _base_state(message: str = "帮我安排今天的重点") -> dict: return { - 'messages': [HumanMessage(content=message)], - 'user_id': 'u1', - 'conversation_id': 'c1', - 'current_agent': AgentRole.MASTER, - 'active_agents': [AgentRole.MASTER], - 'current_sub_commander': None, - 'active_sub_commanders': [], - 'sub_commander_trace': [], - 'pending_tasks': [], - 'completed_tasks': [], - 'tool_calls': [], - 'last_tool_result': None, - 'action_results': [], - 'created_entities': [], - 'tool_strategy_used': None, - 'provider_capabilities': None, - 'fallback_parse_error': None, - 'knowledge_context': None, - 'graph_context': None, - 'schedule_context_summary': None, - 'plan': None, - 'plan_steps': [], - 'analysis_report': None, - 'final_response': None, - 'should_respond': True, - 'memory_context': None, - 'current_datetime_context': 'CURRENT_TIME: 2026-03-28T12:00:00+08:00', - 'current_datetime_reference': {'current_time_iso': '2026-03-28T12:00:00+08:00', 'current_date_iso': '2026-03-28', 'timezone': 'UTC'}, - 'user_llm_config': user_llm_config, + "messages": [HumanMessage(content=message)], + "user_id": "u1", + "conversation_id": "c1", + "current_agent": AgentRole.MASTER.value, + "next_step": None, + "agent_trace": [AgentRole.MASTER.value], + "pending_tasks": [], + "completed_tasks": [], + "created_entities": [], + "knowledge_context": None, + "schedule_context_summary": None, + "analysis_report": None, + "final_response": None, + "memory_context": None, + "current_datetime_context": None, + "user_llm_config": None, + "provider_capabilities": None, } -class FakeFallbackLLM: - def __init__(self, first_content: str, followup_content: str = '已创建提醒:开会,时间为 2026-03-29 09:00(按当前时间理解为“明天早上9点”)。'): - self.first_content = first_content - self.followup_content = followup_content - self.calls = 0 - - async def ainvoke(self, messages): - self.calls += 1 - if self.calls == 1: - return AIMessage(content=self.first_content) - return AIMessage(content=self.followup_content) - - def bind_tools(self, tools): - raise AssertionError('bind_tools should not be called in JSON fallback mode') - - -class FakeNativeBoundLLM: - async def ainvoke(self, messages): - return AIMessage( - content='', - tool_calls=[ - { - 'id': 'call_1', - 'name': 'create_reminder', - 'args': {'title': '开会', 'reminder_at': '明天 09:00'}, - } - ], - ) - - -class FakeNativeLLM: - def __init__(self): - self.bound = FakeNativeBoundLLM() - self.tool_binding_count = 0 - self.calls = 0 - self._jarvis_provider_capabilities = SimpleNamespace(provider='openai', supports_native_tools=True, preferred_tool_strategy='native') - - def bind_tools(self, tools): - self.tool_binding_count += 1 - return self.bound - - async def ainvoke(self, messages): - self.calls += 1 - return AIMessage(content='已创建提醒:开会,时间为 2026-03-29 09:00(按当前时间理解为“明天早上9点”)。') - - -class FakeTool: - def __init__(self, name: str, result: str): - self.name = name - self.result = result - self.invocations: list[dict] = [] - - def invoke(self, args: dict): - self.invocations.append(args) - return self.result - - -class CapturingLLM: - def __init__(self, content: str = '{"mode":"final","final_response":"好的。"}'): - self.content = content - self.messages = None - self._jarvis_provider_capabilities = SimpleNamespace(provider='ollama', supports_native_tools=False, preferred_tool_strategy='json_fallback') - - async def ainvoke(self, messages): - self.messages = messages - return AIMessage(content=self.content) - - class FailIfCalledLLM: async def ainvoke(self, messages): - raise AssertionError('LLM should not be called for simple greetings') + raise AssertionError("LLM should not be called for greeting fast-path") -async def test_master_node_returns_stable_reply_for_simple_greeting(monkeypatch): - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: FailIfCalledLLM()) +class StaticResponseLLM: + def __init__(self, response: AIMessage): + self.response = response + self.messages = None - state = { - 'messages': [HumanMessage(content='你好')], - 'user_id': 'u1', - 'conversation_id': 'c1', - 'current_agent': AgentRole.MASTER, - 'active_agents': [AgentRole.MASTER], - 'pending_tasks': [], - 'completed_tasks': [], - 'tool_calls': [], - 'last_tool_result': None, - 'knowledge_context': None, - 'graph_context': None, - 'plan': None, - 'plan_steps': [], - 'analysis_report': None, - 'final_response': None, - 'should_respond': True, - 'memory_context': None, - 'user_llm_config': None, - } - - result = await master_node(state) - - assert result['final_response'] == '您好。我在。\n\n您把问题给我,我先帮您收束重点,再往下推。' - assert result['current_agent'] == AgentRole.MASTER - assert result['active_agents'] == [AgentRole.MASTER] + async def ainvoke(self, messages): + self.messages = messages + return self.response -async def test_master_node_returns_stable_reply_for_identity_question(monkeypatch): - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: FailIfCalledLLM()) +class CaptureFallbackLLM: + def __init__(self, response: AIMessage): + self.response = response + self.messages = None + self.bind_tools_called = False - state = { - 'messages': [HumanMessage(content='你是谁')], - 'user_id': 'u1', - 'conversation_id': 'c1', - 'current_agent': AgentRole.MASTER, - 'active_agents': [AgentRole.MASTER], - 'pending_tasks': [], - 'completed_tasks': [], - 'tool_calls': [], - 'last_tool_result': None, - 'knowledge_context': None, - 'graph_context': None, - 'plan': None, - 'plan_steps': [], - 'analysis_report': None, - 'final_response': None, - 'should_respond': True, - 'memory_context': None, - 'user_llm_config': None, - } + async def ainvoke(self, messages): + self.messages = messages + return self.response - result = await master_node(state) - - assert result['final_response'] == '我是 Jarvis。\n\n比起做一个泛泛的助手,我更像您的判断型协作伙伴:帮您看清问题、压缩路径、把事情往前推进。' - assert result['current_agent'] == AgentRole.MASTER - assert result['active_agents'] == [AgentRole.MASTER] + def bind_tools(self, tools): + self.bind_tools_called = True + raise AssertionError("bind_tools should not be used when native tools are unsupported") -async def test_master_node_returns_stable_reply_for_identity_question_with_punctuation(monkeypatch): - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: FailIfCalledLLM()) +class AsyncFakeTool: + def __init__(self, name: str, result: str): + self.name = name + self.result = result + self.calls: list[dict] = [] - state = { - 'messages': [HumanMessage(content='你是谁?')], - 'user_id': 'u1', - 'conversation_id': 'c1', - 'current_agent': AgentRole.MASTER, - 'active_agents': [AgentRole.MASTER], - 'pending_tasks': [], - 'completed_tasks': [], - 'tool_calls': [], - 'last_tool_result': None, - 'knowledge_context': None, - 'graph_context': None, - 'plan': None, - 'plan_steps': [], - 'analysis_report': None, - 'final_response': None, - 'should_respond': True, - 'memory_context': None, - 'user_llm_config': None, - } - - result = await master_node(state) - - assert result['final_response'] == '我是 Jarvis。\n\n比起做一个泛泛的助手,我更像您的判断型协作伙伴:帮您看清问题、压缩路径、把事情往前推进。' - assert result['current_agent'] == AgentRole.MASTER - assert result['active_agents'] == [AgentRole.MASTER] + async def ainvoke(self, args: dict): + self.calls.append(args) + return self.result -async def test_master_node_returns_stable_reply_for_identity_question_with_particle(monkeypatch): - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: FailIfCalledLLM()) +class SyncFakeTool: + def __init__(self, name: str, result: str): + self.name = name + self.result = result + self.calls: list[dict] = [] - state = { - 'messages': [HumanMessage(content='你是谁啊')], - 'user_id': 'u1', - 'conversation_id': 'c1', - 'current_agent': AgentRole.MASTER, - 'active_agents': [AgentRole.MASTER], - 'pending_tasks': [], - 'completed_tasks': [], - 'tool_calls': [], - 'last_tool_result': None, - 'knowledge_context': None, - 'graph_context': None, - 'plan': None, - 'plan_steps': [], - 'analysis_report': None, - 'final_response': None, - 'should_respond': True, - 'memory_context': None, - 'user_llm_config': None, - } - - result = await master_node(state) - - assert result['final_response'] == '我是 Jarvis。\n\n比起做一个泛泛的助手,我更像您的判断型协作伙伴:帮您看清问题、压缩路径、把事情往前推进。' - assert result['current_agent'] == AgentRole.MASTER - assert result['active_agents'] == [AgentRole.MASTER] + def invoke(self, args: dict): + self.calls.append(args) + return self.result -async def test_master_node_returns_stable_reply_for_capability_question(monkeypatch): - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: FailIfCalledLLM()) +async def test_master_node_greeting_fast_path_returns_stable_reply_without_llm(monkeypatch): + monkeypatch.setattr("app.agents.graph._get_llm_for_state", lambda state: (FailIfCalledLLM(), SimpleNamespace())) - state = { - 'messages': [HumanMessage(content='你能做什么')], - 'user_id': 'u1', - 'conversation_id': 'c1', - 'current_agent': AgentRole.MASTER, - 'active_agents': [AgentRole.MASTER], - 'pending_tasks': [], - 'completed_tasks': [], - 'tool_calls': [], - 'last_tool_result': None, - 'knowledge_context': None, - 'graph_context': None, - 'plan': None, - 'plan_steps': [], - 'analysis_report': None, - 'final_response': None, - 'should_respond': True, - 'memory_context': None, - 'user_llm_config': None, - } + result = await master_node(_base_state("你好")) - result = await master_node(state) - - assert result['final_response'] == '主要做三件事。\n- 帮您判断:看问题本质、梳理取舍、给出方向\n- 帮您收束:把复杂内容理顺,把重点拎出来\n- 帮您推进:拆任务、定步骤、把下一步变清楚\n\n如果您现在有具体目标,我可以直接进入处理。' - assert result['current_agent'] == AgentRole.MASTER - assert result['active_agents'] == [AgentRole.MASTER] + assert result["final_response"] == "您好。我在。\n\n您把问题给我,我先帮您收束重点,再往下推。" + assert result["messages"][0].content == "您好。我在。" -def test_choose_sub_commander_routes_schedule_requests_to_schedule_planning(): - assert _choose_sub_commander(AgentRole.SCHEDULE_PLANNER, '帮我安排一下这周计划') == 'schedule_planning' - - -def test_choose_sub_commander_routes_focus_requests_to_schedule_analysis(): - assert _choose_sub_commander(AgentRole.SCHEDULE_PLANNER, '基于最近对话帮我判断该聚焦什么') == 'schedule_analysis' - - -def test_route_agent_from_user_query_routes_knowledge_requests_to_librarian(): - assert _route_agent_from_user_query('帮我搜索知识库里的项目资料') == AgentRole.LIBRARIAN - - -def test_route_agent_from_user_query_routes_schedule_requests_to_schedule_planner(): - assert _route_agent_from_user_query('明天提醒我开会') == AgentRole.SCHEDULE_PLANNER - - -def test_route_agent_from_user_query_routes_explicit_month_day_milestone_to_schedule_planner(): - assert _route_agent_from_user_query('3月29日,对话系统交付节点') == AgentRole.SCHEDULE_PLANNER - - -def test_choose_sub_commander_routes_explicit_month_day_milestone_to_schedule_planning(): - assert _choose_sub_commander(AgentRole.SCHEDULE_PLANNER, '3月29日,对话系统交付节点') == 'schedule_planning' - - - - -def test_parse_json_action_extracts_tool_calls_from_fenced_json(): - parsed = _parse_json_action( - '```json\n{"mode":"tool_call","tool_calls":[{"name":"create_reminder","arguments":{"title":"开会","reminder_at":"明天 09:00"}}]}\n```', - ['create_reminder'], +async def test_master_node_routes_to_agent_when_llm_returns_role_name(monkeypatch): + llm = StaticResponseLLM(AIMessage(content="schedule_planner")) + monkeypatch.setattr( + "app.agents.graph._get_llm_for_state", + lambda state: (llm, SimpleNamespace(provider="test", supports_native_tools=True)), ) - assert parsed == { - 'mode': 'tool_call', - 'tool_calls': [ - { - 'name': 'create_reminder', - 'args': {'title': '开会', 'reminder_at': '明天 09:00'}, - 'reason': None, - } + state = _base_state("帮我安排这周重点") + result = await master_node(state) + + assert result["current_agent"] == AgentRole.SCHEDULE_PLANNER.value + assert result["agent_trace"] == [AgentRole.MASTER.value, AgentRole.SCHEDULE_PLANNER.value] + assert result["messages"][0].content == f"已分发至 {AgentRole.SCHEDULE_PLANNER.value} 处理。" + assert isinstance(llm.messages[0], SystemMessage) + assert MASTER_SYSTEM_PROMPT in llm.messages[0].content + + +async def test_master_node_returns_final_response_when_llm_answers_directly(monkeypatch): + response = AIMessage(content="我建议先收束需求,再拆执行步骤。") + llm = StaticResponseLLM(response) + monkeypatch.setattr( + "app.agents.graph._get_llm_for_state", + lambda state: (llm, SimpleNamespace(provider="test", supports_native_tools=True)), + ) + + result = await master_node(_base_state("现在应该怎么推进这个项目?")) + + assert result["final_response"] == response.content + assert result["messages"] == [response] + + +def test_route_after_agent_sends_tool_calls_to_tools_node(): + state = _base_state() + state["messages"] = [AIMessage(content="", tool_calls=[{"id": "1", "name": "create_task", "args": {}}])] + + assert route_after_agent(state) == "tools" + + +def test_route_after_agent_ends_when_no_tool_calls_exist(): + state = _base_state() + state["messages"] = [AIMessage(content="done")] + + assert route_after_agent(state) == END + + +def test_route_master_ends_when_final_response_exists(): + state = _base_state() + state["final_response"] = "done" + state["current_agent"] = AgentRole.EXECUTOR.value + + assert route_master(state) == END + + +def test_route_master_returns_current_agent_when_more_work_remains(): + state = _base_state() + state["current_agent"] = AgentRole.LIBRARIAN.value + + assert route_master(state) == AgentRole.LIBRARIAN.value + + +def test_get_role_tools_returns_expected_semantic_tool_sets(): + expected_by_role = { + AgentRole.SCHEDULE_PLANNER: [ + "get_schedule_day", + "get_tasks", + "resolve_time_expression", + "create_todo", + "create_schedule_task", + "create_reminder", + "create_goal", + ], + AgentRole.EXECUTOR: [ + "get_tasks", + "create_task", + "update_task_status", + "resolve_time_expression", + "create_todo", + "create_schedule_task", + "create_reminder", + "create_goal", + "get_forum_posts", + "create_forum_post", + "scan_forum_for_instructions", + ], + AgentRole.LIBRARIAN: [ + "search_knowledge", + "hybrid_search", + "web_search", + "get_knowledge_graph_context", + "build_knowledge_graph", + ], + AgentRole.ANALYST: [ + "get_tasks", + "get_forum_posts", + "scan_forum_for_instructions", + "search_knowledge", + "hybrid_search", + "web_search", ], } - -def test_parse_json_action_returns_none_for_invalid_or_unknown_payload(): - assert _parse_json_action('not json', ['create_reminder']) is None - assert _parse_json_action('{"mode":"tool_call","tool_calls":[{"name":"unknown","arguments":{}}]}', ['create_reminder']) is None + for role, expected_tool_names in expected_by_role.items(): + actual_tools = _get_role_tools(role) + actual_tool_names = [tool.name for tool in actual_tools] + assert actual_tool_names == expected_tool_names + assert len(actual_tool_names) == len(set(actual_tool_names)) -def test_parse_json_action_tolerates_prefix_and_suffix_text(): - parsed = _parse_json_action( - '好的,下面是 JSON:\n```json\n{"mode":"tool_call","tool_calls":[{"name":"create_reminder","arguments":{"title":"开会","reminder_at":"明天 09:00"}}]}\n```\n谢谢', - ['create_reminder'], - ) - assert parsed is not None - assert parsed['mode'] == 'tool_call' - assert parsed['tool_calls'][0]['name'] == 'create_reminder' +async def test_execute_tools_node_executes_tool_calls_and_tracks_created_entities(monkeypatch): + create_tool = AsyncFakeTool("create_task", "created task 123") + read_tool = SyncFakeTool("get_tasks", "[]") - -def test_parse_json_action_accepts_parameters_alias_for_tool_calls(): - parsed = _parse_json_action( - '{"mode":"tool_call","tool_calls":[{"name":"create_reminder","parameters":{"title":"收被子","reminder_at":"2026-03-29T09:00:00+08:00"}}]}', - ['create_reminder'], + monkeypatch.setattr("app.agents.graph.ALL_TOOLS", [create_tool, read_tool]) + monkeypatch.setattr( + "app.agents.graph.normalize_tool_time_arguments", + lambda tool_name, tool_args, current_datetime_context: {**tool_args, "normalized": True}, ) - assert parsed == { - 'mode': 'tool_call', - 'tool_calls': [ - { - 'name': 'create_reminder', - 'args': {'title': '收被子', 'reminder_at': '2026-03-29T09:00:00+08:00'}, - 'reason': None, - } - ], - } + state = _base_state() + state["created_entities"] = [{"tool": "existing", "result": "already there"}] + state["current_datetime_context"] = "2026-04-02T09:00:00+08:00" + state["messages"] = [ + AIMessage( + content="", + tool_calls=[ + {"id": "tool-1", "name": "create_task", "args": {"title": "Write tests"}}, + {"id": "tool-2", "name": "get_tasks", "args": {"status": "open"}}, + ], + ) + ] + + result = await execute_tools_node(state) + + assert create_tool.calls == [{"title": "Write tests", "normalized": True}] + assert read_tool.calls == [{"status": "open", "normalized": True}] + assert [type(message) for message in result["messages"]] == [ToolMessage, ToolMessage] + assert result["messages"][0].tool_call_id == "tool-1" + assert result["messages"][0].name == "create_task" + assert result["messages"][0].content == "created task 123" + assert result["messages"][1].tool_call_id == "tool-2" + assert result["messages"][1].name == "get_tasks" + assert result["messages"][1].content == "[]" + assert result["created_entities"] == [ + {"tool": "existing", "result": "already there"}, + {"tool": "create_task", "result": "created task 123"}, + ] -async def test_run_sub_commander_uses_json_fallback_for_non_native_provider(monkeypatch): - fake_llm = FakeFallbackLLM( - '{"mode":"tool_call","tool_calls":[{"name":"create_reminder","arguments":{"title":"开会","reminder_at":"明天 09:00"}}]}' +async def test_call_agent_llm_includes_context_messages_and_uses_json_fallback(monkeypatch): + llm = CaptureFallbackLLM(AIMessage(content='{"mode":"final","final_response":"好的。"}')) + capabilities = SimpleNamespace( + provider="ollama", + supports_native_tools=False, + preferred_tool_strategy="json_fallback", ) - fake_tool = FakeTool('create_reminder', '成功创建 reminder: 开会 @ 明天 09:00') + fake_tools = [SimpleNamespace(name="create_reminder"), SimpleNamespace(name="get_tasks")] - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm) - monkeypatch.setitem( - __import__('app.agents.graph', fromlist=['SUB_COMMANDER_TOOLSETS']).SUB_COMMANDER_TOOLSETS, - 'schedule_planning', - [fake_tool], - ) + monkeypatch.setattr("app.agents.graph._get_llm_for_state", lambda state: (llm, capabilities)) + monkeypatch.setattr("app.agents.graph._get_role_tools", lambda role: fake_tools) + monkeypatch.setattr("app.agents.graph.build_skill_context", lambda role_key: "技能上下文: 先判断,再执行") - state = _base_state('明天 9 点提醒我开会', {'provider': 'ollama', 'model': 'qwen2.5'}) - state['current_agent'] = AgentRole.SCHEDULE_PLANNER + state = _base_state("明天提醒我开会") + state["messages"] = [HumanMessage(content="明天提醒我开会")] + state["current_datetime_context"] = "CURRENT_TIME: 2026-04-02T09:00:00+08:00" + state["memory_context"] = "用户偏好早上处理深度工作。" - result = await _run_sub_commander( - state, - AgentRole.SCHEDULE_PLANNER, - 'manager prompt', - '明天 9 点提醒我开会', - use_tools=True, - ) + result = await call_agent_llm(state, AgentRole.EXECUTOR, "executor system prompt") - assert result['tool_strategy_used'] == 'json_fallback' - assert fake_tool.invocations == [{'title': '开会', 'reminder_at': '2026-03-29T09:00:00'}] - assert result['tool_calls'][0]['name'] == 'create_reminder' - assert result['created_entities'][0]['type'] == 'reminder' - assert result['fallback_parse_error'] is None - assert result['final_response'] == '已创建提醒:开会,时间为 2026-03-29 09:00(按当前时间理解为“明天早上9点”)。' + assert result["messages"][0].content == '{"mode":"final","final_response":"好的。"}' + assert llm.bind_tools_called is False + assert llm.messages is not None - -async def test_run_sub_commander_includes_current_datetime_context_in_system_messages(monkeypatch): - fake_llm = CapturingLLM('{"mode":"final","final_response":"好的。"}') - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm) - - state = _base_state('明天 9 点提醒我开会', {'provider': 'ollama', 'model': 'qwen2.5'}) - state['current_agent'] = AgentRole.SCHEDULE_PLANNER - state['current_datetime_context'] = 'CURRENT_TIME: 2026-03-28T12:00:00+08:00' - - await _run_sub_commander( - state, - AgentRole.SCHEDULE_PLANNER, - 'manager prompt', - '明天 9 点提醒我开会', - use_tools=True, - ) - - assert fake_llm.messages is not None - assert any( - getattr(m, 'type', None) == 'system' and 'CURRENT_TIME:' in str(getattr(m, 'content', '')) - for m in fake_llm.messages - ) - - -async def test_run_sub_commander_uses_web_search_in_json_fallback(monkeypatch): - fake_llm = FakeFallbackLLM( - '{"mode":"tool_call","tool_calls":[{"name":"web_search","arguments":{"query":"Jarvis 最新模型更新","top_k":2}}]}', - '我查了外部网页,下面是最新结果摘要。', - ) - fake_tool = FakeTool('web_search', '成功搜索到 2 条网页结果') - - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm) - monkeypatch.setitem( - __import__('app.agents.graph', fromlist=['SUB_COMMANDER_TOOLSETS']).SUB_COMMANDER_TOOLSETS, - 'librarian_retrieval', - [fake_tool], - ) - - state = _base_state('帮我上网查一下 Jarvis 最新模型更新', {'provider': 'ollama', 'model': 'qwen2.5'}) - state['current_agent'] = AgentRole.LIBRARIAN - - result = await _run_sub_commander( - state, - AgentRole.LIBRARIAN, - 'manager prompt', - '帮我上网查一下 Jarvis 最新模型更新', - use_tools=True, - summary_target='knowledge_context', - ) - - assert result['tool_strategy_used'] == 'json_fallback' - assert fake_tool.invocations == [{'query': 'Jarvis 最新模型更新', 'top_k': 2}] - assert result['tool_calls'][0]['name'] == 'web_search' - assert result['last_tool_result'] == '[web_search] 成功搜索到 2 条网页结果' - assert result['final_response'] == '我查了外部网页,下面是最新结果摘要。' - - - fake_llm = FakeNativeLLM() - fake_tool = FakeTool('create_reminder', '成功创建 reminder: 开会 @ 明天 09:00') - - monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: fake_llm) - monkeypatch.setitem( - __import__('app.agents.graph', fromlist=['SUB_COMMANDER_TOOLSETS']).SUB_COMMANDER_TOOLSETS, - 'schedule_planning', - [fake_tool], - ) - - state = _base_state('明天 9 点提醒我开会', {'provider': 'openai', 'model': 'gpt-4o'}) - state['current_agent'] = AgentRole.SCHEDULE_PLANNER - - result = await _run_sub_commander( - state, - AgentRole.SCHEDULE_PLANNER, - 'manager prompt', - '明天 9 点提醒我开会', - use_tools=True, - ) - - assert result['tool_strategy_used'] == 'native' - assert fake_llm.tool_binding_count == 1 - assert fake_tool.invocations == [{'title': '开会', 'reminder_at': '2026-03-29T09:00:00'}] - assert result['created_entities'][0]['type'] == 'reminder' - assert result['final_response'] == '已创建提醒:开会,时间为 2026-03-29 09:00(按当前时间理解为“明天早上9点”)。' + system_contents = [message.content for message in llm.messages if isinstance(message, SystemMessage)] + assert "executor system prompt" in system_contents[0] + assert any("当前时间上下文: CURRENT_TIME: 2026-04-02T09:00:00+08:00" == content for content in system_contents) + assert any("长期记忆上下文: 用户偏好早上处理深度工作。" == content for content in system_contents) + assert any("技能上下文: 先判断,再执行" == content for content in system_contents) + assert any(content == JSON_ACTION_FALLBACK_PROMPT for content in system_contents) + assert any(content == "本次可用工具列表: create_reminder, get_tasks" for content in system_contents) + assert any(isinstance(message, HumanMessage) and message.content == "明天提醒我开会" for message in llm.messages) diff --git a/backend/tests/backend/app/agents/test_registry.py b/backend/tests/backend/app/agents/test_registry.py new file mode 100644 index 0000000..16e3fd6 --- /dev/null +++ b/backend/tests/backend/app/agents/test_registry.py @@ -0,0 +1,360 @@ +import pytest +from collections.abc import Mapping + +from app.agents.prompts import ( + SUB_COMMANDER_PROMPTS_BY_KEY, + TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY, +) +from app.agents.registry import build_registry_indexes, load_builtin_registry_bundle +from app.agents.registry.indexes import summarize_registry_indexes +from app.agents.registry.models import ( + AgentManifest, + CapabilityManifest, + SpecialistTemplateManifest, + SubCommanderManifest, +) +from app.agents.registry.validator import validate_registry_bundle +from app.agents.registry.builtins import ( + BUILTIN_AGENT_MANIFESTS, + BUILTIN_CAPABILITY_MANIFESTS, + BUILTIN_SPECIALIST_TEMPLATE_MANIFESTS, + BUILTIN_SUB_COMMANDER_MANIFESTS, +) +from app.agents.state import AgentRole +from app.agents.tools import SUB_COMMANDER_TOOLSETS + + +def make_agent( + agent_id: str = "master", + *, + display_name: str = "Master", + role_value: str = "master", + system_prompt_key: str = "master", + default_sub_commanders: list[str] | None = None, +) -> AgentManifest: + return AgentManifest( + agent_id=agent_id, + display_name=display_name, + role_value=role_value, + system_prompt_key=system_prompt_key, + routing_hints=["route"], + default_sub_commanders=default_sub_commanders or [], + ) + + +def make_sub_commander( + sub_commander_id: str = "planner", + *, + parent_agent_id: str = "master", + capability_ids: list[str] | None = None, +) -> SubCommanderManifest: + return SubCommanderManifest( + sub_commander_id=sub_commander_id, + parent_agent_id=parent_agent_id, + prompt_text="Plan the work.", + capability_ids=capability_ids or [], + ) + + +def make_capability(capability_id: str = "calendar") -> CapabilityManifest: + return CapabilityManifest(capability_id=capability_id, tool_name=f"{capability_id}_tool") + + +def make_specialist_template( + template_id: str = "researcher", + *, + allowed_capability_ids: list[str] | None = None, +) -> SpecialistTemplateManifest: + return SpecialistTemplateManifest( + template_id=template_id, + display_name="Researcher", + description="Research specialist", + allowed_capability_ids=allowed_capability_ids, + ) + + +def test_validate_registry_bundle_accepts_valid_bundle() -> None: + validate_registry_bundle( + agents=[make_agent(default_sub_commanders=["planner"])], + sub_commanders=[make_sub_commander(capability_ids=["calendar"])], + capabilities=[make_capability()], + specialist_templates=[make_specialist_template(allowed_capability_ids=["calendar"])], + ) + + +def test_validate_registry_bundle_rejects_duplicate_agent_ids() -> None: + agents = [ + make_agent(default_sub_commanders=["planner"]), + make_agent( + display_name="Duplicate Master", + role_value="master_duplicate", + system_prompt_key="master_duplicate", + ), + ] + + with pytest.raises(ValueError, match="duplicate agent id: master"): + validate_registry_bundle( + agents=agents, + sub_commanders=[], + capabilities=[], + specialist_templates=[], + ) + + +def test_validate_registry_bundle_rejects_duplicate_sub_commander_ids() -> None: + with pytest.raises(ValueError, match="duplicate sub commander id: planner"): + validate_registry_bundle( + agents=[make_agent()], + sub_commanders=[make_sub_commander(), make_sub_commander()], + capabilities=[], + specialist_templates=[], + ) + + +def test_validate_registry_bundle_rejects_duplicate_capability_ids() -> None: + with pytest.raises(ValueError, match="duplicate capability id: calendar"): + validate_registry_bundle( + agents=[], + sub_commanders=[], + capabilities=[make_capability(), make_capability()], + specialist_templates=[], + ) + + +def test_validate_registry_bundle_rejects_duplicate_template_ids() -> None: + with pytest.raises(ValueError, match="duplicate template id: researcher"): + validate_registry_bundle( + agents=[], + sub_commanders=[], + capabilities=[], + specialist_templates=[make_specialist_template(), make_specialist_template()], + ) + + +def test_validate_registry_bundle_rejects_unknown_sub_commander_parent_agent_ids() -> None: + sub_commanders = [make_sub_commander(parent_agent_id="missing-agent")] + + with pytest.raises(ValueError, match="unknown parent agent id: missing-agent"): + validate_registry_bundle( + agents=[], + sub_commanders=sub_commanders, + capabilities=[], + specialist_templates=[], + ) + + +def test_validate_registry_bundle_rejects_unknown_sub_commander_capability_references() -> None: + with pytest.raises(ValueError, match="unknown capability id: search"): + validate_registry_bundle( + agents=[make_agent(default_sub_commanders=["planner"])], + sub_commanders=[make_sub_commander(capability_ids=["search"])], + capabilities=[make_capability()], + specialist_templates=[], + ) + + +def test_validate_registry_bundle_rejects_unknown_specialist_template_capability_references() -> None: + with pytest.raises(ValueError, match="unknown capability id: missing-capability"): + validate_registry_bundle( + agents=[], + sub_commanders=[], + capabilities=[make_capability()], + specialist_templates=[ + make_specialist_template(allowed_capability_ids=["missing-capability"]) + ], + ) + + +def test_registry_bundle_agent_roles_match_runtime_agent_role_enum_values() -> None: + bundle = load_builtin_registry_bundle() + indexes = build_registry_indexes(bundle) + + assert set(indexes.agent_by_id) == {role.value for role in AgentRole} + assert {agent.role_value for agent in bundle.agents} == {role.value for role in AgentRole} + + +def test_registry_bundle_agent_system_prompt_keys_match_runtime_top_level_prompt_surface() -> None: + bundle = load_builtin_registry_bundle() + indexes = build_registry_indexes(bundle) + + expected_prompt_keys_by_agent_id = { + role.value: role.value for role in AgentRole if role.value in TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY + } + + assert set(TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY) == {role.value for role in AgentRole} + assert indexes.agent_prompt_key_by_id == expected_prompt_keys_by_agent_id + assert { + agent.agent_id: TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY[agent.system_prompt_key] + for agent in bundle.agents + } == { + role.value: TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY[role.value] + for role in AgentRole + } + + +def test_registry_bundle_skill_context_keys_match_graph_role_derivation_rule() -> None: + bundle = load_builtin_registry_bundle() + indexes = build_registry_indexes(bundle) + + expected_skill_context_keys = { + role.value: role.value.replace("agent_", "") + for role in AgentRole + } + + assert indexes.skill_context_key_by_agent_id == expected_skill_context_keys + assert { + agent.agent_id: agent.skill_context_key for agent in bundle.agents + } == expected_skill_context_keys + + +def test_registry_bundle_sub_commander_prompt_texts_match_runtime_prompt_map() -> None: + bundle = load_builtin_registry_bundle() + indexes = build_registry_indexes(bundle) + + assert set(indexes.sub_commander_by_id) == set(SUB_COMMANDER_PROMPTS_BY_KEY) + assert indexes.sub_commander_prompt_key_by_id == { + sub_commander_id: sub_commander_id + for sub_commander_id in SUB_COMMANDER_PROMPTS_BY_KEY + } + assert { + sub_commander.sub_commander_id: sub_commander.prompt_text + for sub_commander in bundle.sub_commanders + } == SUB_COMMANDER_PROMPTS_BY_KEY + + +def test_registry_bundle_sub_commander_tool_membership_and_order_match_runtime_toolsets() -> None: + bundle = load_builtin_registry_bundle() + indexes = build_registry_indexes(bundle) + + assert set(indexes.sub_commander_by_id) == set(SUB_COMMANDER_TOOLSETS) + assert indexes.capability_ids_by_sub_commander_id == { + sub_commander_id: tuple(tool.name for tool in tools) + for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items() + } + assert { + sub_commander.sub_commander_id: tuple(sub_commander.capability_ids) + for sub_commander in bundle.sub_commanders + } == { + sub_commander_id: tuple(tool.name for tool in tools) + for sub_commander_id, tools in SUB_COMMANDER_TOOLSETS.items() + } + + +def test_builtin_capabilities_reference_actual_runtime_tool_names() -> None: + expected_tool_names = { + tool.name + for tools in SUB_COMMANDER_TOOLSETS.values() + for tool in tools + } + manifest_tool_names = {manifest.tool_name for manifest in BUILTIN_CAPABILITY_MANIFESTS} + + assert manifest_tool_names == expected_tool_names + + +def test_builtin_sub_commander_capabilities_match_runtime_toolsets() -> None: + capabilities_by_tool_name = { + manifest.tool_name: manifest.capability_id for manifest in BUILTIN_CAPABILITY_MANIFESTS + } + + for sub_commander in BUILTIN_SUB_COMMANDER_MANIFESTS: + expected_capability_ids = { + capabilities_by_tool_name[tool.name] + for tool in SUB_COMMANDER_TOOLSETS[sub_commander.sub_commander_id] + } + assert set(sub_commander.capability_ids) == expected_capability_ids + + +def test_builtin_manifests_form_a_valid_registry_bundle() -> None: + validate_registry_bundle( + agents=list(BUILTIN_AGENT_MANIFESTS), + sub_commanders=list(BUILTIN_SUB_COMMANDER_MANIFESTS), + capabilities=list(BUILTIN_CAPABILITY_MANIFESTS), + specialist_templates=list(BUILTIN_SPECIALIST_TEMPLATE_MANIFESTS), + ) + + +def test_load_builtin_registry_bundle_returns_non_empty_manifest_sets() -> None: + bundle = load_builtin_registry_bundle() + + assert bundle.agents + assert bundle.sub_commanders + assert bundle.capabilities + assert isinstance(bundle.specialist_templates, tuple) + + +def test_build_registry_indexes_exposes_manifest_lookups_by_id() -> None: + bundle = load_builtin_registry_bundle() + + indexes = build_registry_indexes(bundle) + + assert indexes.agent_by_id + assert indexes.sub_commander_by_id + assert indexes.capability_by_id + assert isinstance(indexes.specialist_template_by_id, Mapping) + assert set(indexes.agent_by_id) == {agent.agent_id for agent in bundle.agents} + assert set(indexes.sub_commander_by_id) == { + sub_commander.sub_commander_id for sub_commander in bundle.sub_commanders + } + assert set(indexes.capability_by_id) == { + capability.capability_id for capability in bundle.capabilities + } + assert set(indexes.specialist_template_by_id) == { + template.template_id for template in bundle.specialist_templates + } + + +def test_summarize_registry_indexes_returns_read_only_debug_counts() -> None: + bundle = load_builtin_registry_bundle() + indexes = build_registry_indexes(bundle) + + assert summarize_registry_indexes(indexes) == { + "agent_count": len(bundle.agents), + "sub_commander_count": len(bundle.sub_commanders), + "capability_count": len(bundle.capabilities), + "specialist_template_count": len(bundle.specialist_templates), + } + + +def test_build_registry_indexes_exposes_prompt_keys_skill_context_keys_and_capability_mappings() -> None: + bundle = load_builtin_registry_bundle() + + indexes = build_registry_indexes(bundle) + + assert indexes.agent_prompt_key_by_id == { + agent.agent_id: agent.system_prompt_key for agent in bundle.agents + } + assert indexes.agent_prompt_key_by_id == { + agent.agent_id: agent.system_prompt_key for agent in BUILTIN_AGENT_MANIFESTS + } + assert set(indexes.agent_prompt_key_by_id.values()) == set(TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY) + assert indexes.sub_commander_prompt_key_by_id == { + sub_commander.sub_commander_id: sub_commander.sub_commander_id + for sub_commander in bundle.sub_commanders + } + assert set(indexes.sub_commander_prompt_key_by_id.values()) == { + sub_commander.sub_commander_id for sub_commander in bundle.sub_commanders + } + assert indexes.skill_context_key_by_agent_id == { + agent.agent_id: agent.skill_context_key + for agent in bundle.agents + if agent.skill_context_key is not None + } + assert indexes.capability_ids_by_sub_commander_id == { + sub_commander.sub_commander_id: tuple(sub_commander.capability_ids) + for sub_commander in bundle.sub_commanders + } + + +def test_validate_registry_bundle_accepts_loaded_builtin_registry_bundle() -> None: + bundle = load_builtin_registry_bundle() + + validate_registry_bundle( + agents=list(bundle.agents), + sub_commanders=list(bundle.sub_commanders), + capabilities=list(bundle.capabilities), + specialist_templates=list(bundle.specialist_templates), + ) + + +def test_phase_one_still_declares_specialist_template_surface_even_if_runtime_is_deferred() -> None: + assert isinstance(BUILTIN_SPECIALIST_TEMPLATE_MANIFESTS, tuple) diff --git a/backend/uv.lock b/backend/uv.lock index 36c3110..58754ad 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -4,7 +4,8 @@ requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", ] [[package]] @@ -258,6 +259,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "banks" version = "2.4.1" @@ -1297,6 +1307,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "hf-xet" version = "1.4.2" @@ -1329,6 +1352,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1393,6 +1425,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + [[package]] name = "httpx-retries" version = "0.4.6" @@ -1425,6 +1462,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, ] +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -1508,6 +1554,7 @@ dependencies = [ { name = "langsmith" }, { name = "llama-index" }, { name = "llama-index-vector-stores-chroma" }, + { name = "mem0ai" }, { name = "mineru" }, { name = "openpyxl" }, { name = "passlib", extra = ["bcrypt"] }, @@ -1552,6 +1599,7 @@ requires-dist = [ { name = "langsmith", specifier = ">=0.1.0" }, { name = "llama-index", specifier = ">=0.12.0" }, { name = "llama-index-vector-stores-chroma", specifier = ">=0.3.0" }, + { name = "mem0ai", specifier = ">=1.0.0" }, { name = "mineru", specifier = ">=2.0.3" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, { name = "openpyxl", specifier = ">=3.1.0" }, @@ -2431,6 +2479,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mem0ai" +version = "1.0.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai" }, + { name = "posthog" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "qdrant-client" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/d9/1fbf24b055f9f14ec61d593bc95b75b033fd1a69bfddee33d6453b21e5d0/mem0ai-1.0.10.tar.gz", hash = "sha256:f3e22c9aff695ca6c66631c4e79ceef92457c8d3355c56359ab4257fa031c046", size = 200416, upload-time = "2026-04-01T18:23:27.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/e6/2c6ea68c404757e683da23b942dfff6987fe283ccbf2fa1fb0c128ddbdc6/mem0ai-1.0.10-py3-none-any.whl", hash = "sha256:9ff586c3a39a834042ce6755fc9da2315e284fb622ee773cd344ecca756ccad5", size = 295374, upload-time = "2026-04-01T18:23:25.022Z" }, +] + [[package]] name = "mineru" version = "2.7.6" @@ -3372,6 +3438,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "portalocker" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, +] + +[[package]] +name = "posthog" +version = "7.9.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" }, +] + [[package]] name = "pre-commit" version = "4.5.1" @@ -3996,6 +4091,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -4051,6 +4174,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qdrant-client" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "httpx", extra = ["http2"] }, + { name = "numpy" }, + { name = "portalocker" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/dd/f8a8261b83946af3cd65943c93c4f83e044f01184e8525404989d22a81a5/qdrant_client-1.17.1.tar.gz", hash = "sha256:22f990bbd63485ed97ba551a4c498181fcb723f71dcab5d6e4e43fe1050a2bc0", size = 344979, upload-time = "2026-03-13T17:13:44.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" }, +] + [[package]] name = "qwen-vl-utils" version = "0.0.14"