refactor: unify skills+cli runtime and streamline ag-ui flow

This commit is contained in:
qzl
2026-04-22 17:09:37 +08:00
parent eeed737949
commit 4d55df45ab
111 changed files with 4858 additions and 3264 deletions
@@ -63,8 +63,6 @@ def test_text_end_event_with_bare_fields() -> None:
"stage": "worker",
"status": "success",
"answer": "done",
"key_points": ["point1"],
"result_type": "execution_report",
"suggested_actions": ["action1"],
"ui_schema": {"version": "2.0"},
"inputTokens": 100,
@@ -80,8 +78,6 @@ def test_text_end_event_with_bare_fields() -> None:
assert result["messageId"] == "assistant-run-1"
assert result["status"] == "success"
assert result["answer"] == "done"
assert result["key_points"] == ["point1"]
assert result["result_type"] == "execution_report"
assert result["suggested_actions"] == ["action1"]
assert result["ui_schema"] == {"version": "2.0"}
assert "inputTokens" not in result
@@ -101,8 +97,6 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None:
"stage": "worker",
"status": "success",
"answer": "done",
"key_points": [],
"result_type": "execution_report",
"suggested_actions": [],
"inputTokens": 100,
"outputTokens": 50,
@@ -122,7 +116,7 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None:
assert "model" not in result
def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None:
def test_tool_call_result_agui_event_compiles_tool_ui_hints() -> None:
event = {
"type": "TOOL_CALL_RESULT",
"threadId": "thread-1",
@@ -140,43 +134,14 @@ def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None:
"status": "success",
"title": "Done",
},
"ui_schema": {"version": "2.0"},
}
result = to_agui_wire_event(event)
assert result["type"] == "TOOL_CALL_RESULT"
assert "ui_hints" not in result
assert "ui_schema" not in result
def test_text_message_end_agui_event_compiles_ui_hints_to_ui_schema() -> None:
event = {
"type": "TEXT_MESSAGE_END",
"threadId": "thread-1",
"runId": "run-1",
"messageId": "assistant-1",
"role": "assistant",
"stage": "worker",
"status": "success",
"answer": "done",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
"ui_hints": {
"intent": "message",
"status": "info",
"body": "done",
},
}
result = to_agui_wire_event(event)
assert result["type"] == "TEXT_MESSAGE_END"
assert "ui_hints" not in result
assert isinstance(result.get("ui_schema"), dict)
def test_step_started_internal_event_keeps_step_name() -> None:
internal = {
"type": "step.start",
@@ -83,15 +83,8 @@ async def test_store_persists_worker_output_with_answer_as_content(
"stage": "worker",
"status": "success",
"answer": "worker-answer",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
"error": None,
"ui_hints": {
"intent": "message",
"status": "success",
"sections": [],
},
}
)
@@ -101,7 +94,6 @@ async def test_store_persists_worker_output_with_answer_as_content(
metadata = cast(dict[str, Any], append_kwargs["metadata"])
assert sorted(metadata.keys()) == ["agent_output", "agent_type", "run_id"]
assert metadata["agent_output"]["answer"] == "worker-answer"
assert metadata["agent_output"]["ui_hints"]["intent"] == "message"
assert append_kwargs["cost"] == Decimal("0.123")
assert append_kwargs["visibility_mask"] == ((1 << 0) | (1 << 1))
assert captured["message_delta"] == 1
@@ -165,8 +157,6 @@ async def test_store_sets_history_only_visibility_for_automation_worker_output(
"runtime_mode": "automation",
"status": "success",
"answer": "automation-result",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
"error": None,
}
@@ -194,19 +184,9 @@ async def test_store_persists_router_step_output_for_cost_tracking(
"stepName": "router",
"_router_persist": {
"router_output": {
"normalized_task_input": {
"user_text": "安排明天会议",
"context_summary": "",
},
"key_entities": [],
"constraints": [],
"task_typing": {"primary": "scheduling"},
"execution_mode": "tool_assisted",
"result_typing": {"primary": "execution_report"},
"ui": {
"ui_mode": "none",
"ui_decision_reason": "单任务",
},
"objective": "安排明天会议",
"context_summary": "",
"requires_tool_evidence": True,
},
"response_metadata": {
"model": "doubao-seed-1-6-250615",
@@ -51,7 +51,7 @@ def _run_input() -> RunAgentInput:
def _runtime_config() -> RuntimeConfig:
return RuntimeConfig(
enabled_tools=[],
enabled_skills=[],
context=MessageContextConfig(),
)
@@ -7,13 +7,7 @@ from ag_ui.core import RunAgentInput
import core.agentscope.runtime.runner as runner_module
from core.agentscope.runtime.runner import AgentScopeRunner
from schemas.agent.runtime_models import (
ExecutionMode,
NormalizedTaskInput,
ResultType,
ResultTyping,
RouterAgentOutput,
TaskType,
TaskTyping,
WorkerAgentOutputLite,
)
from schemas.agent.system_agent import AgentType
@@ -46,7 +40,7 @@ def _user_context() -> UserContext:
def _runtime_config() -> RuntimeConfig:
return RuntimeConfig(
enabled_tools=[],
enabled_skills=[],
context=MessageContextConfig(),
)
@@ -54,15 +48,9 @@ def _runtime_config() -> RuntimeConfig:
def test_build_worker_input_messages_only_contains_router_contract() -> None:
runner = AgentScopeRunner()
router_output = RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(
user_text="安排明天会议",
context_summary="用户询问天气",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
objective="安排明天会议",
context_summary="用户询问天气",
requires_tool_evidence=True,
)
input_messages = runner._build_worker_input_messages(router_output=router_output)
@@ -224,15 +212,9 @@ async def test_execute_runs_router_then_worker(
async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput:
del kwargs
return RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(
user_text="安排会议",
context_summary="用户询问天气",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
objective="安排会议",
context_summary="用户询问天气",
requires_tool_evidence=True,
)
async def _fake_execute_worker_step(**kwargs: object) -> WorkerAgentOutputLite:
@@ -254,7 +236,7 @@ async def test_execute_runs_router_then_worker(
)
assert load_calls == [AgentType.ROUTER, AgentType.WORKER]
assert result["router"]["normalized_task_input"]["user_text"] == "安排会议"
assert result["router"]["objective"] == "安排会议"
assert result["worker"]["answer"] == "ok"
@@ -289,15 +271,9 @@ async def test_execute_raises_cancelled_error_before_worker_when_cancel_requeste
async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput:
del kwargs
return RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(
user_text="安排会议",
context_summary="",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
objective="安排会议",
context_summary="",
requires_tool_evidence=False,
)
async def _fake_execute_worker_step(**kwargs: object) -> WorkerAgentOutputLite:
@@ -98,7 +98,7 @@ async def test_run_agentscope_task_calls_runtime_run(
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"enabled_skills": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
@@ -154,7 +154,7 @@ async def test_run_agentscope_task_injects_runtime_config(
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"enabled_skills": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
@@ -218,7 +218,7 @@ async def test_run_agentscope_task_injects_cancel_checker(
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"enabled_skills": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
@@ -0,0 +1,85 @@
from __future__ import annotations
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
class TestToolAgentOutputResultCoercion:
def test_dict_result_stays_dict(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0, "items": []},
)
assert isinstance(output.result, dict)
assert output.result == {"total": 0, "items": []}
def test_list_result_stays_list(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result=[{"id": "evt_1"}],
)
assert isinstance(output.result, list)
def test_string_result_stays_string(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result="not json",
)
assert isinstance(output.result, str)
assert output.result == "not json"
def test_none_result_stays_none(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.FAILURE,
result=None,
error={"code": "ERR", "message": "fail", "retryable": False},
)
assert output.result is None
def test_ui_hints_preserved(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0, "items": []},
ui_hints={"view": "calendar_event_list", "total": 0},
)
assert output.ui_hints == {"view": "calendar_event_list", "total": 0}
def test_model_dump_includes_ui_hints(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0, "items": []},
ui_hints={"view": "calendar_event_list"},
)
dumped = output.model_dump(mode="json", exclude_none=True)
assert "ui_hints" in dumped
assert dumped["ui_hints"] == {"view": "calendar_event_list"}
assert isinstance(dumped["result"], dict)
def test_model_dump_excludes_none_ui_hints(self) -> None:
output = ToolAgentOutput(
tool_name="project_cli",
tool_call_id="call-1",
tool_call_args={},
status=ToolStatus.SUCCESS,
result={"total": 0},
ui_hints=None,
)
dumped = output.model_dump(mode="json", exclude_none=True)
assert "ui_hints" not in dumped
@@ -11,7 +11,7 @@ def test_build_agent_prompt_for_worker_contains_runtime_config() -> None:
{
"temperature": 0.2,
"context_messages": {"mode": "number", "count": 20},
"enabled_tools": ["calendar.read", "calendar.write"],
"enabled_skills": ["calendar", "contacts"],
}
),
)
@@ -20,10 +20,10 @@ def test_build_agent_prompt_for_worker_contains_runtime_config() -> None:
assert "- type: worker" in prompt
assert "context_messages.mode=number" in prompt
assert "context_messages.count=20" in prompt
assert "enabled_tools=calendar.read,calendar.write" in prompt
assert "enabled_skills=calendar,contacts" in prompt
def test_build_agent_prompt_for_router_contains_task_typing_rules() -> None:
def test_build_agent_prompt_for_router_contains_identity_and_config() -> None:
prompt = build_agent_prompt(
agent_type=AgentType.ROUTER,
llm_config=SystemAgentLLMConfig.model_validate(
@@ -1,400 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, cast
from uuid import UUID, uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom import calendar as calendar_module
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
assert response.content
first = response.content[0]
if isinstance(first, dict):
text = str(first.get("text", ""))
else:
text = str(getattr(first, "text", ""))
return json.loads(text)
@dataclass
class _FakeService:
created_request: Any = None
created_id: str = field(default_factory=lambda: str(uuid4()))
list_calls: list[dict[str, Any]] = field(default_factory=list)
range_calls: list[dict[str, Any]] = field(default_factory=list)
deleted_ids: list[str] = field(default_factory=list)
async def list_paginated(
self, *, page: int, page_size: int, query: str | None = None
):
self.list_calls.append({"page": page, "page_size": page_size, "query": query})
item = SimpleNamespace(
id=UUID(self.created_id),
title="会议",
description="今天下午五点的会议",
start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
status="active",
metadata=SimpleNamespace(
location=None, color="#4F46E5", reminder_minutes=15
),
)
return [item], 1
async def list_by_date_range(self, request: Any):
self.range_calls.append(
{
"start_at": request.start_at,
"end_at": request.end_at,
}
)
return [
SimpleNamespace(
id=UUID(self.created_id),
owner_id=uuid4(),
title="会议",
description="今天下午五点的会议",
start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
status="active",
source_type="manual",
metadata=None,
subscribers=[],
)
]
async def create_agent_generated(self, request):
self.created_request = request
return SimpleNamespace(
id=UUID(self.created_id),
title=request.title,
description=request.description,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
metadata=request.metadata,
)
async def delete(self, item_id: UUID) -> None:
self.deleted_ids.append(str(item_id))
async def share(self, item_id: UUID, request: Any) -> None:
if not hasattr(self, "share_calls"):
self.share_calls = []
self.share_calls.append({"item_id": str(item_id), "request": request})
@pytest.mark.asyncio
async def test_calendar_write_requires_runtime_context() -> None:
result = await calendar_module.calendar_write(
operations=[calendar_module.CalendarWriteOperation(action="create")]
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_calendar_write_create_requires_start_at(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "start_at" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_write_create_requires_event_timezone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
start_at="2026-03-16T09:00:00+08:00",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "event_timezone" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_write_rejects_naive_start_at(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
start_at="2026-03-16T09:00:00",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "时区" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_write_create_normalizes_to_utc(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
title="晨会",
start_at="2026-03-16T09:00:00+08:00",
end_at="2026-03-16T10:00:00+08:00",
event_timezone="Asia/Shanghai",
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success")
assert "items=[{status=success,eventId=" in payload["result"]
assert fake_service.created_id in payload["result"]
assert fake_service.created_request is not None
request = fake_service.created_request
assert request.timezone == "Asia/Shanghai"
assert request.start_at == datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc)
assert request.end_at == datetime(2026, 3, 16, 2, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_calendar_write_batch_supports_create_and_delete(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=[
calendar_module.CalendarWriteOperation(
action="create",
title="晨会",
start_at="2026-03-16T09:00:00+08:00",
event_timezone="Asia/Shanghai",
),
calendar_module.CalendarWriteOperation(
action="delete",
event_id=str(uuid4()),
),
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert "success=2" in payload["result"]
assert len(fake_service.deleted_ids) == 1
@pytest.mark.asyncio
async def test_calendar_read_returns_structured_result_with_ids(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_read(
start_at="2026-03-17T00:00:00+08:00",
end_at="2026-03-18T00:00:00+08:00",
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
result_data = json.loads(payload["result"])
assert payload["status"] == "success"
assert result_data["total"] == 1
assert result_data["items"][0]["id"] == fake_service.created_id
assert result_data["items"][0]["timezone"] == "Asia/Shanghai"
assert result_data["items"][0]["description"] == "今天下午五点的会议"
assert result_data["items"][0]["status"] == "active"
assert fake_service.range_calls == [
{
"start_at": datetime(2026, 3, 16, 16, 0, tzinfo=timezone.utc),
"end_at": datetime(2026, 3, 17, 16, 0, tzinfo=timezone.utc),
}
]
@pytest.mark.asyncio
async def test_calendar_read_rejects_naive_datetime_string(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_read(
start_at="2026-03-17T00:00:00",
end_at="2026-03-18T00:00:00+08:00",
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "时区" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_share_executes_with_valid_invitee(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=[
calendar_module.CalendarShareInvitee(
phone="13900001234",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success success=1 failed=0")
assert "+8613900001234" in payload["result"]
assert len(fake_service.share_calls) == 1
share_call = fake_service.share_calls[0]
assert share_call["item_id"] == event_id
assert share_call["request"].phone == "+8613900001234"
@pytest.mark.asyncio
async def test_calendar_share_rejects_invalid_phone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_share(
event_id=str(uuid4()),
invitees=[
calendar_module.CalendarShareInvitee(
phone="12345",
permissionView=True,
permissionEdit=False,
permissionInvite=False,
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_share_accepts_json_invitee_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
event_id = str(uuid4())
result = await calendar_module.calendar_share(
event_id=event_id,
invitees=cast(
Any,
[
{
"phone": "8613900001234",
"permissionView": True,
"permissionEdit": False,
"permissionInvite": False,
}
],
),
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert payload["result"].startswith("status=success success=1 failed=0")
assert len(fake_service.share_calls) == 1
share_call = fake_service.share_calls[0]
assert share_call["item_id"] == event_id
assert share_call["request"].phone == "+8613900001234"
@@ -1,170 +0,0 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from uuid import uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom import memory as memory_module
from models.memories import MemoryType
from schemas.domain.memory_content import UserMemoryContent
def _decode_tool_response(response: ToolResponse) -> dict[str, object]:
assert response.content
first = response.content[0]
text = str(first.get("text", "")) if isinstance(first, dict) else str(first.text)
return json.loads(text)
def _payload_error_code(payload: dict[str, object]) -> str:
error = payload.get("error")
if not isinstance(error, dict):
return ""
return str(error.get("code") or "")
class _FakeMemoriesService:
def __init__(self) -> None:
self.memory: object | None = None
self.updated_user = 0
self.updated_work = 0
async def get_memory_model(self, *, memory_type: MemoryType):
_ = memory_type
return self.memory
async def update_user_memory(self, **kwargs):
_ = kwargs
self.updated_user += 1
return SimpleNamespace()
async def update_work_memory(self, **kwargs):
_ = kwargs
self.updated_work += 1
return SimpleNamespace()
def _user_memory():
return SimpleNamespace(
id=uuid4(),
owner_id=uuid4(),
memory_type=MemoryType.USER,
content={"preferences": {"communication_style": "简洁"}},
status="active",
)
@pytest.mark.asyncio
async def test_memory_write_requires_runtime_context() -> None:
response = await memory_module.memory_write(
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["跑步"]),
)
],
)
payload = _decode_tool_response(response)
assert payload["status"] == "failure"
assert _payload_error_code(payload) == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_memory_write_updates_user_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeMemoriesService()
monkeypatch.setattr(
memory_module, "create_memories_service", lambda **_: fake_service
)
response = await memory_module.memory_write(
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["阅读"]),
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "success"
assert "success=1" in str(payload["result"])
assert "updated_types=[user]" in str(payload["result"])
assert fake_service.updated_user == 1
@pytest.mark.asyncio
async def test_memory_forget_updates_content_paths(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeMemoriesService()
fake_service.memory = _user_memory()
monkeypatch.setattr(
memory_module, "create_memories_service", lambda **_: fake_service
)
response = await memory_module.memory_forget(
operations=[
memory_module.MemoryForgetArgs(
memory_type=MemoryType.USER,
forget_paths=["preferences.communication_style"],
)
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "success"
assert "success=1" in str(payload["result"])
assert "forgotten=1" in str(payload["result"])
assert fake_service.updated_user == 1
@pytest.mark.asyncio
async def test_memory_write_partial_status_contains_error_details(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeMemoriesService()
call_count = 0
async def _update_user_memory(**kwargs):
nonlocal call_count
_ = kwargs
call_count += 1
if call_count == 2:
raise ValueError("invalid payload")
fake_service.updated_user += 1
return SimpleNamespace()
fake_service.update_user_memory = _update_user_memory # type: ignore[method-assign]
monkeypatch.setattr(
memory_module, "create_memories_service", lambda **_: fake_service
)
response = await memory_module.memory_write(
operations=[
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["阅读"]),
),
memory_module.MemoryWriteArgs(
memory_type=MemoryType.USER,
user_content=UserMemoryContent(interests=["跑步"]),
),
],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(response)
assert payload["status"] == "partial"
assert "status=partial" in str(payload["result"])
assert "failed=1" in str(payload["result"])
assert _payload_error_code(payload) in {"INVALID_ARGUMENT", "UNKNOWN_ERROR"}
@@ -1,25 +0,0 @@
from __future__ import annotations
from core.agentscope.prompts.route_prompt import (
build_frontend_route_prompt,
load_frontend_routes_catalog,
)
def test_load_frontend_routes_catalog_contains_known_routes() -> None:
catalog = load_frontend_routes_catalog()
assert catalog.version == "1.0"
route_ids = {route.route_id for route in catalog.routes}
assert "home.main" in route_ids
assert "calendar.event_detail" in route_ids
assert "todo.detail" in route_ids
def test_build_frontend_route_prompt_has_guidance_and_routes() -> None:
prompt = build_frontend_route_prompt()
assert "[Frontend Route Catalog]" in prompt
assert "version=1.0" in prompt
assert "route_id=home.main; path=/home;" in prompt
assert "route_id=calendar.event_detail; path=/calendar/events/{id};" in prompt
@@ -137,22 +137,12 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication
agent_type=AgentType.WORKER,
user_context=_build_user_context(),
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
tools=[
{
"name": "calendar.read",
"description": "读取日程",
"parameters": {"type": "object"},
}
],
)
assert "[Identity]" in prompt
assert "[Runtime Context]" in prompt
assert "<!-- ROUTE_START -->" in prompt
assert "[Safety Rules]" in prompt
assert "[Frontend Route Catalog]" in prompt
assert "[Agent Identity]" in prompt
assert "[Available Tools]" in prompt
assert "[Answer Style]" in prompt
assert "Default reply language:" not in prompt
assert "Follow agent contracts strictly" not in prompt
@@ -1,22 +0,0 @@
from __future__ import annotations
from core.agentscope.prompts.tool_prompt import build_tools_prompt
def test_build_tools_prompt_wraps_section_and_schema() -> None:
prompt = build_tools_prompt(
tools=[
{
"name": "calendar.read",
"description": "读取日程",
"parameters": {
"type": "object",
"properties": {"page": {"type": "integer"}},
},
}
]
)
assert "<!-- TOOLS_START -->" in prompt
assert "calendar.read" in prompt
assert '"page":{"type":"integer"}' in prompt
@@ -0,0 +1,105 @@
from __future__ import annotations
import json
import sys
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from core.agentscope.tools.tool_call_context import store_tool_agent_output, peek_tool_agent_output, consume_tool_agent_output
def _load_parsing_module():
module_name = "test_agentscope_parsing"
module_path = (
Path(__file__).resolve().parents[4] / "src/core/agentscope/utils/parsing.py"
)
spec = spec_from_file_location(module_name, module_path)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
parsing_module = _load_parsing_module()
parse_tool_agent_output = parsing_module.parse_tool_agent_output
project_tool_result_text = parsing_module.project_tool_result_text
def test_project_tool_result_text_returns_json_projection() -> None:
result = {
"status": "success",
"items": [{"id": "evt_1"}],
}
projected = project_tool_result_text(result)
assert projected == '{"status":"success","items":[{"id":"evt_1"}]}'
def test_parse_tool_agent_output_uses_side_channel_payload() -> None:
tool_call_id = "call-1"
store_tool_agent_output(
tool_call_id=tool_call_id,
payload={
"tool_name": "calendar.write",
"tool_call_id": tool_call_id,
"tool_call_args": {"title": "Sync"},
"status": "success",
"result": {
"status": "success",
"event": {"id": "evt_1"},
},
"ui_hints": {"view": "calendar_event_created"},
},
)
output = [{"type": "text", "text": json.dumps({"status": "success"})}]
parsed = parse_tool_agent_output(
output,
tool_call_id=tool_call_id,
tool_name="calendar.write",
tool_call_args={"title": "Sync"},
)
assert parsed is not None
assert parsed.tool_name == "calendar.write"
assert parsed.tool_call_id == tool_call_id
assert parsed.result == {"status": "success", "event": {"id": "evt_1"}}
assert parsed.ui_hints == {"view": "calendar_event_created"}
def test_peek_does_not_consume() -> None:
tool_call_id = "peek-test-1"
store_tool_agent_output(
tool_call_id=tool_call_id,
payload={
"tool_name": "calendar.read",
"tool_call_id": tool_call_id,
"tool_call_args": {},
"status": "success",
"result": {"total": 0, "items": []},
"ui_hints": {"view": "calendar_event_list"},
},
)
peeked = peek_tool_agent_output(tool_call_id=tool_call_id)
assert peeked is not None
assert peeked["tool_name"] == "calendar.read"
assert peeked["ui_hints"] == {"view": "calendar_event_list"}
peeked_again = peek_tool_agent_output(tool_call_id=tool_call_id)
assert peeked_again is not None
consumed = consume_tool_agent_output(tool_call_id=tool_call_id)
assert consumed is not None
after_consume = peek_tool_agent_output(tool_call_id=tool_call_id)
assert after_consume is None
def test_peek_returns_none_for_missing() -> None:
result = peek_tool_agent_output(tool_call_id="nonexistent")
assert result is None
@@ -1,30 +0,0 @@
from __future__ import annotations
from typing import Any, cast
from uuid import uuid4
from core.agentscope.tools.toolkit import build_stage_toolkit
from schemas.agent.system_agent import AgentType
def test_build_stage_toolkit_uses_explicit_enabled_tools_as_final_set(
monkeypatch,
) -> None:
captured: dict[str, object] = {}
def _fake_build_toolkit(**kwargs):
captured.update(kwargs)
return object()
monkeypatch.setattr(
"core.agentscope.tools.toolkit.build_toolkit", _fake_build_toolkit
)
build_stage_toolkit(
agent_type=AgentType.WORKER,
session=cast(Any, object()),
owner_id=uuid4(),
enabled_tool_names={"calendar_read", "user_lookup"},
)
assert captured["enabled_tool_names"] == {"calendar_read", "user_lookup"}
@@ -1,34 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.toolkit import build_toolkit
@pytest.mark.asyncio
async def test_build_toolkit_registers_calendar_tools() -> None:
pytest.importorskip("agentscope")
toolkit = build_toolkit(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
)
schemas = toolkit.get_json_schemas()
names = {item["function"]["name"] for item in schemas}
assert "calendar_read" in names
assert "calendar_write" in names
assert "calendar_share" in names
assert "memory_write" in names
assert "memory_forget" in names
write_schema = next(
item for item in schemas if item["function"]["name"] == "calendar_write"
)
params = write_schema["function"]["parameters"]["properties"]
assert "user_token" not in params
assert "session" not in params
assert "owner_id" not in params
@@ -1,63 +0,0 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from uuid import uuid4
import pytest
from agentscope.tool import ToolResponse
from core.agentscope.tools.custom.user_lookup import user_lookup
import core.agentscope.tools.custom.user_lookup as user_lookup_module
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
assert response.content
first = response.content[0]
if isinstance(first, dict):
text = str(first.get("text", ""))
else:
text = str(getattr(first, "text", ""))
return json.loads(text)
@pytest.mark.asyncio
async def test_user_lookup_requires_runtime_context() -> None:
result = await user_lookup()
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_user_lookup_returns_friend_contacts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_list_friend_contacts(**_: Any) -> list[dict[str, str]]:
return [
{
"userId": "00000000-0000-0000-0000-000000000101",
"username": "alice",
"phone": "+8613900000001",
},
{
"userId": "00000000-0000-0000-0000-000000000102",
"username": "bob",
"phone": "+8613900000002",
},
]
monkeypatch.setattr(
user_lookup_module,
"_list_friend_contacts",
_fake_list_friend_contacts,
)
result = await user_lookup(session=SimpleNamespace(), owner_id=uuid4())
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert "friends_count=2" in payload["result"]
assert "username=alice" in payload["result"]
assert "+8613900000001" in payload["result"]
@@ -0,0 +1,62 @@
from __future__ import annotations
import pytest
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
from core.agentscope.tools.cli.router import CommandRouter
@pytest.mark.asyncio
async def test_router_register_and_dispatch() -> None:
router = CommandRouter()
async def mock_handler(request: CliCommand) -> CliCommandResult:
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand, data={"name": request.args["name"]})
router.register(command="test", subcommand="run", handler=mock_handler)
assert ("test", "run") in router.command_pairs
result = await router.dispatch(CliCommand(command="test", subcommand="run", args={"name": "demo"}, owner_id="u1"))
assert result.ok is True
assert result.data == {"name": "demo"}
@pytest.mark.asyncio
async def test_router_unknown_command() -> None:
router = CommandRouter()
result = await router.dispatch(CliCommand(command="unknown", subcommand="run", args={}, owner_id="u1"))
assert result.ok is False
assert result.error is not None
assert result.error.code == "UNKNOWN_COMMAND"
@pytest.mark.asyncio
async def test_router_handler_exception() -> None:
router = CommandRouter()
async def failing_handler(request: CliCommand) -> CliCommandResult:
del request
raise ValueError("intentional error")
router.register(command="fail", subcommand="run", handler=failing_handler)
result = await router.dispatch(CliCommand(command="fail", subcommand="run", args={}, owner_id="u1"))
assert result.ok is False
assert result.error is not None
assert result.error.code == "HANDLER_ERROR"
def test_router_duplicate_register() -> None:
router = CommandRouter()
async def handler1(request: CliCommand) -> CliCommandResult:
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand)
async def handler2(request: CliCommand) -> CliCommandResult:
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand)
router.register(command="cmd", subcommand="one", handler=handler1)
with pytest.raises(ValueError, match="already registered"):
router.register(command="cmd", subcommand="one", handler=handler2)
@@ -0,0 +1,73 @@
from __future__ import annotations
from core.agentscope.tools.tool_postprocessor import postprocess_tool_output
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
def _make_tool_output(
*,
command: str,
subcommand: str,
status: ToolStatus,
data: dict | None = None,
) -> ToolAgentOutput:
return ToolAgentOutput(
tool_name="project_cli",
tool_call_id="test_call_id",
tool_call_args={"command": command, "subcommand": subcommand, "args": {}},
status=status,
result={"command": command, "subcommand": subcommand, "data": data or {}},
error=None,
ui_hints=None,
)
def test_postprocess_calendar_read_success() -> None:
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5, "items": []})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["view"] == "calendar_event_list"
assert processed.ui_hints["total"] == 5
def test_postprocess_calendar_write_partial() -> None:
output = _make_tool_output(command="calendar", subcommand="write", status=ToolStatus.PARTIAL, data={"status": "partial", "results": []})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["view"] == "calendar_batch_result"
assert processed.ui_hints["status"] == "partial"
def test_postprocess_contacts_lookup_success() -> None:
output = _make_tool_output(command="contacts", subcommand="lookup", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["view"] == "contact_list"
assert processed.ui_hints["friends_count"] == 3
def test_postprocess_memory_forget_success() -> None:
output = _make_tool_output(command="memory", subcommand="forget", status=ToolStatus.SUCCESS, data={"status": "success", "forgotten": 5})
processed = postprocess_tool_output(output)
assert processed.ui_hints is not None
assert processed.ui_hints["forgotten"] == 5
def test_postprocess_failure_no_ui_hints() -> None:
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.FAILURE, data=None)
processed = postprocess_tool_output(output)
assert processed.ui_hints is None
def test_postprocess_unknown_command_no_ui_hints() -> None:
output = _make_tool_output(command="unknown", subcommand="run", status=ToolStatus.SUCCESS, data={"data": "test"})
processed = postprocess_tool_output(output)
assert processed.ui_hints is None
def test_postprocess_preserves_existing_ui_hints() -> None:
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5})
output = output.model_copy(update={"ui_hints": {"view": "custom_view", "custom": True}})
processed = postprocess_tool_output(output)
assert processed.ui_hints["view"] == "custom_view"
assert processed.ui_hints["custom"] is True
@@ -0,0 +1,61 @@
import asyncio
from core.agentscope.tools.internal.project_cli import PROJECT_CLI_TOOL_NAME
from core.agentscope.tools.internal.view_skill_file import VIEW_SKILL_FILE_TOOL_NAME
from core.agentscope.tools.internal import make_view_skill_file_wrapper
from core.agentscope.tools.toolkit import build_toolkit
from schemas.agent.skill_config import SkillName
def test_skill_names_is_complete() -> None:
expected = {"calendar", "contacts", "memory"}
assert {skill.value for skill in SkillName} == expected
def test_validate_rejects_unknown_skill() -> None:
from core.agentscope.tools.toolkit import _validate_enabled_skill_names
try:
_validate_enabled_skill_names({"calendar", "nonexistent"})
assert False, "should have raised"
except ValueError as exc:
assert "nonexistent" in str(exc)
def test_validate_accepts_known_skills() -> None:
from core.agentscope.tools.toolkit import _validate_enabled_skill_names
result = _validate_enabled_skill_names({"calendar", "contacts"})
assert result == {"calendar", "contacts"}
def test_build_toolkit_registers_project_cli() -> None:
toolkit = build_toolkit()
schemas = toolkit.get_json_schemas()
assert {item["function"]["name"] for item in schemas} == {
PROJECT_CLI_TOOL_NAME,
VIEW_SKILL_FILE_TOOL_NAME,
}
def test_view_skill_file_rejects_path_outside_enabled_skill_dirs() -> None:
wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"})
response = asyncio.run(
wrapper(file_path="/tmp/not-allowed.txt", ranges=None),
)
assert response.content
block = response.content[0]
text = block["text"] if isinstance(block, dict) else block.text
assert "ACCESS_DENIED" in text or "access denied" in text.lower()
def test_view_skill_file_reads_enabled_skill_file() -> None:
wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"})
response = asyncio.run(wrapper(file_path="calendar/SKILL.md", ranges=[1, 10]))
assert response.content
block = response.content[0]
text = block["text"] if isinstance(block, dict) else block.text
assert "Calendar Skill" in text or "name: calendar" in text