from __future__ import annotations from types import SimpleNamespace import pytest import yuxi.agents.middlewares.subagent_task as subagent_task_middleware from langchain_core.messages import AIMessage, HumanMessage from langgraph.prebuilt.tool_node import ToolRuntime from langgraph.types import Command from yuxi.agents.buildin.chatbot.state import merge_subagent_runs from yuxi.agents.middlewares.subagent_task import YuxiSubAgentMiddleware from yuxi.utils.subagent_thread_utils import make_child_thread_id from yuxi.repositories.agent_repository import SUB_AGENT_BACKEND_ID class _ChildContext: def __init__(self): self.model = None def update_from_dict(self, values: dict): for key, value in values.items(): if hasattr(self, key): setattr(self, key, value) def _patch_subagent_run_tracking(monkeypatch): async def create_run(_self, **kwargs): return SimpleNamespace( id=f"sub-run-{kwargs['tool_call_id']}", request_id=f"sub-req-{kwargs['tool_call_id']}", status="running", parent_agent_run_id="parent-run", created_at=None, finished_at=None, error_message=None, ), True async def set_status(_self, run_id, status, *, error_type=None, error_message=None): del error_type return SimpleNamespace( id=run_id, request_id=run_id.replace("sub-run", "sub-req"), status=status, parent_agent_run_id="parent-run", created_at=None, finished_at=None, error_message=error_message, ) monkeypatch.setattr(YuxiSubAgentMiddleware, "_create_subagent_run", create_run) monkeypatch.setattr(YuxiSubAgentMiddleware, "_set_subagent_run_status", set_status) @pytest.mark.asyncio async def test_create_task_middleware_loads_all_visible_subagents_when_empty(monkeypatch) -> None: class _SessionContext: async def __aenter__(self): return object() async def __aexit__(self, exc_type, exc, tb): return None class _UserRepository: async def get_by_uid_with_db(self, _db, uid): assert uid == "user-1" return SimpleNamespace(uid="user-1", role="user") class _AgentRepository: def __init__(self, _db): pass async def list_visible_subagents(self, *, user): assert user.uid == "user-1" return [ SimpleNamespace( slug="worker", name="Worker", description="work on scoped tasks", backend_id=SUB_AGENT_BACKEND_ID, config_json={}, ), SimpleNamespace( slug="invalid", name="Invalid", description="invalid backend", backend_id="ChatbotAgent", config_json={}, ), ] async def get_visible_subagent_by_slug(self, *, slug, user): raise AssertionError("empty subagents should load all visible subagents") monkeypatch.setattr( subagent_task_middleware, "pg_manager", SimpleNamespace(get_async_session_context=lambda: _SessionContext()), ) monkeypatch.setattr(subagent_task_middleware, "UserRepository", _UserRepository) monkeypatch.setattr(subagent_task_middleware, "AgentRepository", _AgentRepository) middleware = await subagent_task_middleware.create_subagent_task_middleware( SimpleNamespace(thread_id="parent-thread", uid="user-1", subagents=[]) ) assert isinstance(middleware, YuxiSubAgentMiddleware) assert middleware.subagent_names == frozenset({"worker"}) assert middleware.transformers @pytest.mark.asyncio async def test_task_tool_rejects_unconfigured_subagent() -> None: middleware = YuxiSubAgentMiddleware( parent_context=SimpleNamespace(thread_id="parent-thread", uid="user-1"), subagents=[ SimpleNamespace( slug="worker", name="Worker", description="work on scoped tasks", backend_id=SUB_AGENT_BACKEND_ID, config_json={}, ) ], ) runtime = ToolRuntime( state={}, context=None, tool_call_id="tool-1", store=None, stream_writer=lambda _: None, config={}, ) result = await middleware.tools[0].ainvoke( {"description": "do work", "subagent_type": "missing", "runtime": runtime} ) assert result == "无法调用子智能体 missing,可用子智能体只有:`worker`" @pytest.mark.asyncio async def test_task_tool_invokes_subagent_with_child_scope(monkeypatch) -> None: _patch_subagent_run_tracking(monkeypatch) captured = {} class _Graph: async def ainvoke(self, state, *, config, context): captured["state"] = state captured["config"] = config captured["context"] = context return { "messages": [AIMessage(content="child done")], "artifacts": ["/home/gem/user-data/outputs/report.md"], "todos": ["should not merge"], } class _Backend: context_schema = _ChildContext async def get_graph(self, *, context): captured["graph_context"] = context return _Graph() monkeypatch.setattr( subagent_task_middleware, "_get_agent_backend", lambda backend_id: _Backend() if backend_id == SUB_AGENT_BACKEND_ID else None, ) times = iter(["2026-05-31T01:00:00Z", "2026-05-31T01:00:03Z"]) monkeypatch.setattr(subagent_task_middleware, "utc_isoformat", lambda: next(times)) middleware = YuxiSubAgentMiddleware( parent_context=SimpleNamespace( thread_id="child-runtime-thread", parent_thread_id="parent-thread", file_thread_id="parent-file-thread", uid="user-1", ), subagents=[ SimpleNamespace( slug="worker.agent", name="Worker", description="work on scoped tasks", backend_id=SUB_AGENT_BACKEND_ID, config_json={"context": {"model": "provider:model", "subagents": ["nested"]}}, ) ], ) runtime = SimpleNamespace( tool_call_id="tool-1", state={ "messages": [HumanMessage(content="parent")], "todos": ["parent todo"], "activated_skills": ["parent-skill"], "kept": "value", }, config={ "callbacks": ["stream-callback"], "tags": ["parent"], "recursion_limit": 42, "configurable": {"checkpoint_ns": "parent-ns", "__pregel_task_id": "parent-task"}, }, ) result = await middleware.tools[0].coroutine( description="write a report", subagent_type="worker.agent", runtime=runtime, ) child_thread_id = make_child_thread_id("parent-thread", "worker.agent", "tool-1") assert isinstance(result, Command) assert result.update["messages"][0].content == f"> 子智能体线程 ID: {child_thread_id}\n\n---\n\nchild done" assert result.update["messages"][0].tool_call_id == "tool-1" assert result.update["artifacts"] == ["/home/gem/user-data/outputs/report.md"] assert result.update["subagent_runs"] == [ { "id": "tool-1", "subagent_type": "worker.agent", "subagent_name": "Worker", "child_thread_id": child_thread_id, "description": "write a report", "created_at": "2026-05-31T01:00:00Z", "run_id": "sub-run-tool-1", "parent_agent_run_id": "parent-run", "status": "completed", "completed_at": "2026-05-31T01:00:03Z", "result_preview": "child done", "error": None, "artifacts": ["/home/gem/user-data/outputs/report.md"], } ] assert "kept" not in captured["state"] assert captured["state"]["parent_thread_id"] == "parent-thread" assert captured["state"]["file_thread_id"] == "parent-file-thread" assert captured["state"]["skills_thread_id"] == child_thread_id assert captured["state"]["messages"] == [HumanMessage(content="write a report")] assert "todos" not in captured["state"] assert "activated_skills" not in captured["state"] assert captured["config"]["callbacks"] == ["stream-callback"] assert captured["config"]["tags"] == ["parent"] assert captured["config"]["recursion_limit"] == 42 assert captured["config"]["configurable"] == { "thread_id": child_thread_id, "uid": "user-1", "parent_thread_id": "parent-thread", "file_thread_id": "parent-file-thread", "skills_thread_id": child_thread_id, "subagent_type": "worker.agent", "subagent_thread_id": child_thread_id, "subagent_tool_call_id": "tool-1", "run_id": "sub-run-tool-1", "request_id": "sub-req-tool-1", "ls_agent_type": "subagent", } assert captured["context"] is captured["graph_context"] assert captured["context"].model == "provider:model" assert captured["context"].thread_id == child_thread_id assert captured["context"].parent_thread_id == "parent-thread" assert captured["context"].file_thread_id == "parent-file-thread" assert captured["context"].skills_thread_id == child_thread_id assert not hasattr(captured["context"], "subagents") assert captured["context"].is_subagent_runtime is True @pytest.mark.asyncio async def test_task_tool_records_failed_subagent_run(monkeypatch) -> None: _patch_subagent_run_tracking(monkeypatch) class _Graph: async def ainvoke(self, state, *, config, context): del state, config, context raise RuntimeError("child boom") class _Backend: context_schema = _ChildContext async def get_graph(self, *, context): del context return _Graph() monkeypatch.setattr( subagent_task_middleware, "_get_agent_backend", lambda backend_id: _Backend() if backend_id == SUB_AGENT_BACKEND_ID else None, ) times = iter(["2026-05-31T02:00:00Z", "2026-05-31T02:00:04Z"]) monkeypatch.setattr(subagent_task_middleware, "utc_isoformat", lambda: next(times)) middleware = YuxiSubAgentMiddleware( parent_context=SimpleNamespace(thread_id="parent-thread", uid="user-1"), subagents=[ SimpleNamespace( slug="worker", name="Worker", description="work on scoped tasks", backend_id=SUB_AGENT_BACKEND_ID, config_json={}, ) ], ) runtime = SimpleNamespace(tool_call_id="tool-1", state={}, config={}) result = await middleware.tools[0].coroutine( description="write a report", subagent_type="worker", runtime=runtime, ) assert isinstance(result, Command) child_thread_id = make_child_thread_id("parent-thread", "worker", "tool-1") assert ( result.update["messages"][0].content == f"> 子智能体线程 ID: {child_thread_id}\n\n---\n\n子智能体 worker 调用失败:child boom" ) assert result.update["subagent_runs"] == [ { "id": "tool-1", "subagent_type": "worker", "subagent_name": "Worker", "child_thread_id": child_thread_id, "description": "write a report", "created_at": "2026-05-31T02:00:00Z", "run_id": "sub-run-tool-1", "parent_agent_run_id": "parent-run", "status": "failed", "completed_at": "2026-05-31T02:00:04Z", "result_preview": "子智能体 worker 调用失败:child boom", "error": "child boom", "artifacts": [], } ] @pytest.mark.asyncio async def test_task_tool_continues_existing_subagent_thread(monkeypatch) -> None: _patch_subagent_run_tracking(monkeypatch) captured = {} class _Graph: async def ainvoke(self, state, *, config, context): captured["state"] = state captured["config"] = config captured["context"] = context return {"messages": [AIMessage(content="continued done")]} class _Backend: context_schema = _ChildContext async def get_graph(self, *, context): captured["graph_context"] = context return _Graph() monkeypatch.setattr( subagent_task_middleware, "_get_agent_backend", lambda backend_id: _Backend() if backend_id == SUB_AGENT_BACKEND_ID else None, ) times = iter(["2026-05-31T03:00:00Z", "2026-05-31T03:00:05Z"]) monkeypatch.setattr(subagent_task_middleware, "utc_isoformat", lambda: next(times)) child_thread_id = make_child_thread_id("parent-thread", "worker.agent", "tool-old") middleware = YuxiSubAgentMiddleware( parent_context=SimpleNamespace(thread_id="parent-thread", uid="user-1"), subagents=[ SimpleNamespace( slug="worker.agent", name="Worker", description="work on scoped tasks", backend_id=SUB_AGENT_BACKEND_ID, config_json={}, ) ], ) runtime = SimpleNamespace( tool_call_id="tool-2", state={ "subagent_runs": [ { "id": "tool-old", "subagent_type": "worker.agent", "child_thread_id": child_thread_id, "status": "completed", } ], "kept": "parent-value", }, config={"configurable": {"checkpoint_ns": "parent-ns"}}, ) result = await middleware.tools[0].coroutine( description="continue the report", subagent_type="worker.agent", runtime=runtime, thread_id=child_thread_id, ) assert isinstance(result, Command) assert result.update["messages"][0].content == f"> 子智能体线程 ID: {child_thread_id}\n\n---\n\ncontinued done" assert result.update["subagent_runs"] == [ { "id": "tool-2", "subagent_type": "worker.agent", "subagent_name": "Worker", "child_thread_id": child_thread_id, "description": "continue the report", "created_at": "2026-05-31T03:00:00Z", "run_id": "sub-run-tool-2", "parent_agent_run_id": "parent-run", "status": "completed", "completed_at": "2026-05-31T03:00:05Z", "result_preview": "continued done", "error": None, "artifacts": [], } ] assert captured["state"] == { "parent_thread_id": "parent-thread", "file_thread_id": "parent-thread", "skills_thread_id": child_thread_id, "messages": [HumanMessage(content="continue the report")], } assert captured["config"]["configurable"] == { "thread_id": child_thread_id, "uid": "user-1", "parent_thread_id": "parent-thread", "file_thread_id": "parent-thread", "skills_thread_id": child_thread_id, "subagent_type": "worker.agent", "subagent_thread_id": child_thread_id, "subagent_tool_call_id": "tool-2", "run_id": "sub-run-tool-2", "request_id": "sub-req-tool-2", "ls_agent_type": "subagent", } assert captured["context"] is captured["graph_context"] assert captured["context"].thread_id == child_thread_id @pytest.mark.asyncio async def test_task_tool_rejects_invalid_continuation_thread(monkeypatch) -> None: graph_called = False class _Backend: context_schema = _ChildContext async def get_graph(self, *, context): nonlocal graph_called del context graph_called = True raise AssertionError("invalid continuation should not invoke graph") async def reject_continuation(_self, **kwargs): raise ValueError(f"无法继续子智能体线程 {kwargs['child_thread_id']}:当前对话中没有找到对应的子智能体运行记录") monkeypatch.setattr( subagent_task_middleware, "_get_agent_backend", lambda backend_id: _Backend() if backend_id == SUB_AGENT_BACKEND_ID else None, ) monkeypatch.setattr(YuxiSubAgentMiddleware, "_create_subagent_run", reject_continuation) middleware = YuxiSubAgentMiddleware( parent_context=SimpleNamespace(thread_id="parent-thread", uid="user-1", run_id="parent-run"), subagents=[ SimpleNamespace( slug="worker", name="Worker", description="work on scoped tasks", backend_id=SUB_AGENT_BACKEND_ID, config_json={}, ) ], ) unknown_thread_id = "opaque-child-thread" runtime = SimpleNamespace(tool_call_id="tool-2", state={}, config={}) result = await middleware.tools[0].coroutine( description="continue", subagent_type="worker", runtime=runtime, thread_id=unknown_thread_id, ) assert result == f"无法继续子智能体线程 {unknown_thread_id}:当前对话中没有找到对应的子智能体运行记录" assert graph_called is False def test_make_child_thread_id_fits_agent_run_thread_column() -> None: child_thread_id = make_child_thread_id( "fa62c751-d124-476f-a58c-855890aebcc4", "agent-with-a-very-long-slug-that-would-overflow-the-column", "019e86570b418b4ea6b5aee3ef87b1fa", ) assert len(child_thread_id) <= 64 assert child_thread_id == make_child_thread_id( "fa62c751-d124-476f-a58c-855890aebcc4", "agent-with-a-very-long-slug-that-would-overflow-the-column", "019e86570b418b4ea6b5aee3ef87b1fa", ) def test_merge_subagent_runs_reuses_child_thread_entry() -> None: child_thread_id = make_child_thread_id("parent-thread", "worker", "tool-old") merged = merge_subagent_runs( [ { "id": "tool-old", "subagent_type": "worker", "subagent_name": "Worker", "child_thread_id": child_thread_id, "description": "first task", "status": "completed", "created_at": "2026-05-31T01:00:00Z", } ], [ { "id": "tool-new", "subagent_type": "worker", "subagent_name": "Worker", "child_thread_id": child_thread_id, "description": "continue task", "status": "completed", "created_at": "2026-05-31T02:00:00Z", } ], ) assert merged == [ { "id": "tool-new", "subagent_type": "worker", "subagent_name": "Worker", "child_thread_id": child_thread_id, "description": "continue task", "status": "completed", "created_at": "2026-05-31T02:00:00Z", } ]