c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Waiting to run
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Python Unittest Coverage / test (macos-15, 3.11) (push) Waiting to run
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Waiting to run
Python Unittest Coverage / test (windows-latest, 3.11) (push) Waiting to run
Web UI / check (push) Waiting to run
415 lines
14 KiB
Python
415 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=protected-access
|
|
"""Tests for :func:`get_toolkit` — the single entry point that assembles
|
|
the per-chat-turn :class:`Toolkit` from every tool source the framework
|
|
manages.
|
|
|
|
Verifies the assembly rules:
|
|
|
|
- workspace builtins are always included;
|
|
- the four ``Task*`` planning tools are always included;
|
|
- :class:`ToolStop` (from ``BackgroundTaskManager``) is always included;
|
|
- the four ``Schedule*`` tools only when ``session.config.chat_model_config``
|
|
is set (they need a model to fire new runs with);
|
|
- team tools are role-gated by ``agent_record.source``: ``"team"`` →
|
|
one ``TeamSay`` (worker variant); anything else → the full
|
|
leader-side toolset of four;
|
|
- caller-supplied ``extra_factory`` results land at the end.
|
|
"""
|
|
from typing import Any
|
|
from unittest import IsolatedAsyncioTestCase
|
|
|
|
from utils import FakeWorkspaceManager
|
|
from agentscope.agent import ContextConfig, ReActConfig
|
|
from agentscope.app._manager import (
|
|
BackgroundTaskManager,
|
|
SchedulerManager,
|
|
)
|
|
from agentscope.app._service import ResourceAccessService, get_toolkit
|
|
from agentscope.app.access import DenyAllResourceAccessPolicy
|
|
from agentscope.app.storage import (
|
|
AgentData,
|
|
AgentRecord,
|
|
ChatModelConfig,
|
|
SessionConfig,
|
|
SessionRecord,
|
|
TeamData,
|
|
TeamRecord,
|
|
)
|
|
from agentscope.tool import ToolBase
|
|
|
|
|
|
class _FakeWorkspace:
|
|
"""Stand-in for a resolved :class:`WorkspaceBase` — only the three
|
|
methods :func:`get_toolkit` calls are implemented."""
|
|
|
|
def __init__(
|
|
self,
|
|
tools: list[ToolBase] | None = None,
|
|
skills: list | None = None,
|
|
mcps: list | None = None,
|
|
) -> None:
|
|
self._tools = tools or []
|
|
self._skills = skills or []
|
|
self._mcps = mcps or []
|
|
|
|
async def list_tools(self) -> list[ToolBase]:
|
|
"""Return the configured workspace tools."""
|
|
return list(self._tools)
|
|
|
|
async def list_skills(self) -> list:
|
|
"""Return the configured workspace skills."""
|
|
return list(self._skills)
|
|
|
|
async def list_mcps(self) -> list:
|
|
"""Return the configured workspace MCP descriptors."""
|
|
return list(self._mcps)
|
|
|
|
|
|
class _NullBus:
|
|
"""``MessageBus`` placeholder. Team tools only carry the reference;
|
|
nothing in :func:`get_toolkit` actually awaits it."""
|
|
|
|
|
|
def _make_agent(*, source: str = "user", name: str = "A") -> AgentRecord:
|
|
"""Build a minimal :class:`AgentRecord`."""
|
|
return AgentRecord(
|
|
user_id="u",
|
|
source=source,
|
|
data=AgentData(
|
|
name=name,
|
|
system_prompt=f"You are {name}.",
|
|
context_config=ContextConfig(),
|
|
react_config=ReActConfig(),
|
|
),
|
|
)
|
|
|
|
|
|
def _make_session(
|
|
*,
|
|
user_id: str,
|
|
agent_id: str,
|
|
with_model: bool,
|
|
team_id: str | None = None,
|
|
) -> SessionRecord:
|
|
"""Build a minimal :class:`SessionRecord`, optionally with a chat
|
|
model config."""
|
|
cfg = SessionConfig(
|
|
workspace_id="ws",
|
|
chat_model_config=(
|
|
ChatModelConfig(
|
|
type="dashscope_credential",
|
|
credential_id="c",
|
|
model="m",
|
|
parameters={},
|
|
)
|
|
if with_model
|
|
else None
|
|
),
|
|
)
|
|
return SessionRecord(
|
|
user_id=user_id,
|
|
agent_id=agent_id,
|
|
config=cfg,
|
|
team_id=team_id,
|
|
)
|
|
|
|
|
|
class _NoOpStorage:
|
|
"""Storage placeholder. ``get_toolkit`` itself does not call any
|
|
storage method — the team tools bind a reference for later use.
|
|
|
|
Exceptions: since :class:`AgentInvite` landed, ``get_toolkit`` calls
|
|
``ResourceAccessService.list_resource`` at assembly time to build
|
|
the invitable pool, which fans out to
|
|
:meth:`StorageBase.list_agents` under the hood. Return an empty
|
|
list — the toolkit tests care about which team tools are attached,
|
|
not about the pool contents; the non-empty case is covered by the
|
|
``AgentInvite`` tests in ``service_team_tools_test.py``.
|
|
Additionally, when the session has a ``team_id`` set, ``get_toolkit``
|
|
calls ``get_team`` to decide leader-vs-worker; the ``team_id_map``
|
|
constructor arg lets a test inject a lookup so the worker branch
|
|
can be exercised.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
team_id_map: dict[str, TeamRecord] | None = None,
|
|
) -> None:
|
|
"""Initialize the storage placeholder."""
|
|
self._teams = team_id_map or {}
|
|
|
|
async def list_agents(self, _user_id: str) -> list:
|
|
"""List agents for a team."""
|
|
return []
|
|
|
|
async def get_team(
|
|
self,
|
|
_user_id: str,
|
|
team_id: str,
|
|
) -> TeamRecord | None:
|
|
"""Return the team record for ``team_id``."""
|
|
return self._teams.get(team_id)
|
|
|
|
|
|
def _make_access(storage: Any) -> ResourceAccessService:
|
|
"""Build a :class:`ResourceAccessService` bound to ``storage`` with a
|
|
deny-all policy for ``get_toolkit`` tests.
|
|
|
|
``list_resource(AGENT)`` therefore fans out to
|
|
``storage.list_agents(user_id)`` alone — matching the pre-share
|
|
behaviour the toolkit tests were written against.
|
|
"""
|
|
return ResourceAccessService(
|
|
storage=storage,
|
|
policy=DenyAllResourceAccessPolicy(),
|
|
)
|
|
|
|
|
|
def _tool_names(toolkit: Any) -> list[str]:
|
|
"""Extract every registered tool name from a :class:`Toolkit`,
|
|
walking its tool groups."""
|
|
return [t.name for group in toolkit.tool_groups for t in group.tools]
|
|
|
|
|
|
class _StubTool(ToolBase):
|
|
"""Minimal :class:`ToolBase` subclass that satisfies the abstract
|
|
methods so :func:`get_toolkit` can register the instance.
|
|
|
|
Sub-classes override ``name`` and ``description``; nothing in the
|
|
tests actually calls the tool, so the implementations are no-ops.
|
|
"""
|
|
|
|
name: str = "stub"
|
|
description: str = "stub tool"
|
|
input_schema: dict = {}
|
|
is_concurrency_safe: bool = True
|
|
is_read_only: bool = False
|
|
is_state_injected: bool = False
|
|
is_external_tool: bool = False
|
|
is_mcp: bool = False
|
|
mcp_name: str | None = None
|
|
|
|
async def check_permissions(self, *args: Any, **kwargs: Any) -> None:
|
|
"""No-op permission check — the tests do not exercise it."""
|
|
|
|
async def __call__(self, *args: Any, **kwargs: Any) -> None:
|
|
"""No-op invocation — the tests never execute the tool."""
|
|
|
|
|
|
class TestGetToolkitBaseAssembly(IsolatedAsyncioTestCase):
|
|
"""User-owned agent (``source="user"``) gets the full set."""
|
|
|
|
async def test_user_agent_gets_all_sources(self) -> None:
|
|
"""A user-owned agent receives workspace, planning, scheduling,
|
|
ToolStop, and the four leader-side team tools."""
|
|
agent = _make_agent(source="user")
|
|
session = _make_session(
|
|
user_id="u",
|
|
agent_id=agent.id,
|
|
with_model=True,
|
|
)
|
|
|
|
class _WsTool(_StubTool):
|
|
"""Stub workspace tool registered through ``_FakeWorkspace``."""
|
|
|
|
name: str = "ws-bash"
|
|
description: str = "stub workspace tool"
|
|
|
|
ws_tool = _WsTool()
|
|
workspace = _FakeWorkspace(tools=[ws_tool])
|
|
|
|
toolkit = await get_toolkit(
|
|
storage=_NoOpStorage(), # type: ignore[arg-type]
|
|
workspace=workspace, # type: ignore[arg-type]
|
|
workspace_manager=FakeWorkspaceManager(),
|
|
scheduler_manager=SchedulerManager(
|
|
storage=_NoOpStorage(), # type: ignore[arg-type]
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
background_task_manager=BackgroundTaskManager(
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
user_id="u",
|
|
agent_record=agent,
|
|
session_record=session,
|
|
extra_factory=None,
|
|
middlewares=[],
|
|
resource_access_service=_make_access(_NoOpStorage()),
|
|
)
|
|
|
|
names = set(_tool_names(toolkit))
|
|
# Workspace tool present.
|
|
self.assertIn("ws-bash", names)
|
|
# Planning tools present.
|
|
self.assertTrue(
|
|
{"TaskCreate", "TaskList", "TaskGet", "TaskUpdate"} <= names,
|
|
)
|
|
# Background task control present.
|
|
self.assertIn("ToolStop", names)
|
|
# Schedule control present (model_config is set).
|
|
self.assertTrue(
|
|
{
|
|
"ScheduleCreate",
|
|
"ScheduleView",
|
|
"ScheduleDelete",
|
|
"ScheduleList",
|
|
}
|
|
<= names,
|
|
)
|
|
# Leader-side team tools (4 of them).
|
|
self.assertTrue(
|
|
{"TeamCreate", "AgentCreate", "TeamSay", "TeamDelete"} <= names,
|
|
)
|
|
|
|
|
|
class TestGetToolkitWorkerVariant(IsolatedAsyncioTestCase):
|
|
"""A session whose team role is worker only gets ``TeamSay``.
|
|
|
|
The worker/leader distinction is now session-level: a session with
|
|
``team_id`` set and ``team.session_id != session.id`` is a worker
|
|
regardless of the agent record's ``source`` (an ``AgentInvite``
|
|
borrowed session runs on ``source='user'`` but is still a worker)."""
|
|
|
|
async def test_worker_only_gets_team_say(self) -> None:
|
|
"""A worker session receives only ``TeamSay`` from the team
|
|
toolset."""
|
|
agent = _make_agent(source="team", name="worker")
|
|
session = _make_session(
|
|
user_id="u",
|
|
agent_id=agent.id,
|
|
with_model=True,
|
|
team_id="t1",
|
|
)
|
|
# Team exists and its leader session is NOT this one → worker.
|
|
team = TeamRecord(
|
|
user_id="u",
|
|
session_id="leader-sid",
|
|
data=TeamData(name="team", description="d"),
|
|
)
|
|
toolkit = await get_toolkit(
|
|
storage=_NoOpStorage(
|
|
team_id_map={"t1": team},
|
|
), # type: ignore[arg-type]
|
|
workspace=_FakeWorkspace(), # type: ignore[arg-type]
|
|
workspace_manager=FakeWorkspaceManager(),
|
|
scheduler_manager=SchedulerManager(
|
|
storage=_NoOpStorage(), # type: ignore[arg-type]
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
background_task_manager=BackgroundTaskManager(
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
user_id="u",
|
|
agent_record=agent,
|
|
session_record=session,
|
|
extra_factory=None,
|
|
middlewares=[],
|
|
resource_access_service=_make_access(_NoOpStorage()),
|
|
)
|
|
names = set(_tool_names(toolkit))
|
|
# Only TeamSay from the team toolset.
|
|
self.assertIn("TeamSay", names)
|
|
for missing in (
|
|
"TeamCreate",
|
|
"AgentCreate",
|
|
"TeamDelete",
|
|
"AgentInvite",
|
|
):
|
|
self.assertNotIn(missing, names)
|
|
|
|
|
|
class TestGetToolkitSchedulingGuard(IsolatedAsyncioTestCase):
|
|
"""``Schedule*`` tools are only attached when the session has a
|
|
model configured."""
|
|
|
|
async def test_no_schedule_tools_without_model_config(self) -> None:
|
|
"""Without a ``chat_model_config`` on the session, the four
|
|
``Schedule*`` tools are omitted from the toolkit."""
|
|
agent = _make_agent(source="user")
|
|
session = _make_session(
|
|
user_id="u",
|
|
agent_id=agent.id,
|
|
with_model=False,
|
|
)
|
|
toolkit = await get_toolkit(
|
|
storage=_NoOpStorage(), # type: ignore[arg-type]
|
|
workspace=_FakeWorkspace(), # type: ignore[arg-type]
|
|
workspace_manager=FakeWorkspaceManager(),
|
|
scheduler_manager=SchedulerManager(
|
|
storage=_NoOpStorage(), # type: ignore[arg-type]
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
background_task_manager=BackgroundTaskManager(
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
user_id="u",
|
|
agent_record=agent,
|
|
session_record=session,
|
|
extra_factory=None,
|
|
middlewares=[],
|
|
resource_access_service=_make_access(_NoOpStorage()),
|
|
)
|
|
names = set(_tool_names(toolkit))
|
|
for missing in (
|
|
"ScheduleCreate",
|
|
"ScheduleView",
|
|
"ScheduleDelete",
|
|
"ScheduleList",
|
|
):
|
|
self.assertNotIn(missing, names)
|
|
|
|
|
|
class TestGetToolkitExtraFactory(IsolatedAsyncioTestCase):
|
|
"""Tools returned by ``extra_factory`` end up in the final toolkit."""
|
|
|
|
async def test_extra_factory_tools_are_attached(self) -> None:
|
|
"""Tools returned by ``extra_factory`` end up in the final
|
|
toolkit alongside the framework-builtin ones."""
|
|
|
|
class _ExtraTool(_StubTool):
|
|
"""Stub tool emitted by the ``extra_factory`` callback."""
|
|
|
|
name: str = "my-extra"
|
|
description: str = "stub extra tool"
|
|
|
|
extra_tool = _ExtraTool()
|
|
|
|
async def factory(
|
|
_user_id: str,
|
|
_agent_id: str,
|
|
_session_id: str,
|
|
) -> list[ToolBase]:
|
|
"""Stub extra-factory that always returns ``extra_tool``."""
|
|
return [extra_tool]
|
|
|
|
agent = _make_agent()
|
|
session = _make_session(
|
|
user_id="u",
|
|
agent_id=agent.id,
|
|
with_model=True,
|
|
)
|
|
toolkit = await get_toolkit(
|
|
storage=_NoOpStorage(), # type: ignore[arg-type]
|
|
workspace=_FakeWorkspace(), # type: ignore[arg-type]
|
|
workspace_manager=FakeWorkspaceManager(),
|
|
scheduler_manager=SchedulerManager(
|
|
storage=_NoOpStorage(), # type: ignore[arg-type]
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
background_task_manager=BackgroundTaskManager(
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
),
|
|
message_bus=_NullBus(), # type: ignore[arg-type]
|
|
user_id="u",
|
|
agent_record=agent,
|
|
session_record=session,
|
|
extra_factory=factory,
|
|
middlewares=[],
|
|
resource_access_service=_make_access(_NoOpStorage()),
|
|
)
|
|
self.assertIn("my-extra", _tool_names(toolkit))
|