feat(agent): redesign project_cli with module/method/input protocol
- Replace command/subcommand/args with module/method/input envelope - Calendar handler uses discriminated union (mode) for read operations - Strict Pydantic models with extra='forbid' for all calendar methods - Worker max_iters=7, router prompt simplified (removed project_cli_defaults) - Skill index cards + per-action files for progressive disclosure - Frontend/AG-UI aligned to module/method dispatch - Protocol docs updated to module/method/input contract WIP: action cards need envelope fix, 2 tests need update, memory handler needs Pydantic models.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core.agentscope.tools.cli.adapter import invoke_cli_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_cli_requires_module_and_method() -> None:
|
||||
response = await invoke_cli_tool(
|
||||
tool_name="project_cli",
|
||||
tool_call_args={
|
||||
"module": "calendar",
|
||||
"input": {},
|
||||
},
|
||||
allowed_commands={"calendar"},
|
||||
)
|
||||
|
||||
assert response.content
|
||||
block = response.content[0]
|
||||
text = block["text"] if isinstance(block, dict) else block.text
|
||||
payload = json.loads(text)
|
||||
assert payload["ok"] is False
|
||||
assert payload["module"] == "calendar"
|
||||
assert payload["method"] == ""
|
||||
assert payload["error"]["code"] == "INVALID_ARGUMENT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_cli_failure_includes_method_contract_in_side_channel() -> None:
|
||||
from core.agentscope.tools.tool_call_context import (
|
||||
peek_tool_agent_output,
|
||||
reset_current_tool_call_id,
|
||||
set_current_tool_call_id,
|
||||
)
|
||||
from core.auth.credential_issuer import create_credential_issuer
|
||||
from core.auth.tool_credential_context import reset_tool_credential, set_tool_credential
|
||||
|
||||
token = set_current_tool_call_id("call-test-guidance")
|
||||
credential_token = set_tool_credential(
|
||||
create_credential_issuer().issue(
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
mode="chat",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
response = await invoke_cli_tool(
|
||||
tool_name="project_cli",
|
||||
tool_call_args={
|
||||
"module": "calendar",
|
||||
"method": "read",
|
||||
"input": {},
|
||||
},
|
||||
allowed_commands={"calendar"},
|
||||
)
|
||||
finally:
|
||||
reset_tool_credential(credential_token)
|
||||
reset_current_tool_call_id(token)
|
||||
|
||||
assert response.content
|
||||
block = response.content[0]
|
||||
text = block["text"] if isinstance(block, dict) else block.text
|
||||
payload = json.loads(text)
|
||||
assert payload["ok"] is False
|
||||
assert payload["module"] == "calendar"
|
||||
assert payload["method"] == "read"
|
||||
assert payload["data"] is None
|
||||
assert payload["error"]["code"] == "INVALID_ACTION_INPUT"
|
||||
|
||||
stored = peek_tool_agent_output(tool_call_id="call-test-guidance")
|
||||
assert stored is not None
|
||||
error = stored.get("error")
|
||||
assert isinstance(error, dict)
|
||||
assert error["code"] == "INVALID_ACTION_INPUT"
|
||||
assert error["details"]["input_schema"]["mode"] == "string enum(day|range|event)"
|
||||
assert error["details"]["expected_input_examples"][0] == {
|
||||
"mode": "day",
|
||||
"date": "2026-04-24",
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
assert "resolve the day to a concrete input.date value" in error["message"]
|
||||
@@ -1,38 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.agentscope.tools.cli.handler_calendar import (
|
||||
_resolve_read_range,
|
||||
_day_input_to_range_input,
|
||||
_CalendarReadDayInput,
|
||||
handle_calendar_create_event,
|
||||
handle_calendar_list_day,
|
||||
)
|
||||
from core.agentscope.tools.cli.models import CliCommand
|
||||
|
||||
|
||||
def test_resolve_read_range_supports_date_timezone_fallback() -> None:
|
||||
request = CliCommand(
|
||||
command="calendar",
|
||||
subcommand="read",
|
||||
owner_id="u1",
|
||||
args={"date": "2026-04-23", "timezone": "Asia/Shanghai"},
|
||||
def test_day_input_converts_to_tz_range() -> None:
|
||||
payload = _CalendarReadDayInput.model_validate(
|
||||
{"mode": "day", "date": "2026-04-23", "timezone": "Asia/Shanghai"}
|
||||
)
|
||||
|
||||
start_at, end_at, error = _resolve_read_range(request)
|
||||
result = _day_input_to_range_input(payload)
|
||||
|
||||
assert error is None
|
||||
assert start_at is not None
|
||||
assert end_at is not None
|
||||
assert start_at.isoformat() == "2026-04-22T16:00:00+00:00"
|
||||
assert end_at.isoformat() == "2026-04-23T16:00:00+00:00"
|
||||
assert result == {
|
||||
"mode": "range",
|
||||
"start_at": "2026-04-23T00:00:00+08:00",
|
||||
"end_at": "2026-04-24T00:00:00+08:00",
|
||||
}
|
||||
|
||||
|
||||
def test_resolve_read_range_rejects_bad_date() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_read_rejects_bad_date_format() -> None:
|
||||
request = CliCommand(
|
||||
command="calendar",
|
||||
subcommand="read",
|
||||
module="calendar",
|
||||
method="read",
|
||||
owner_id="u1",
|
||||
args={"date": "2026/04/23", "timezone": "Asia/Shanghai"},
|
||||
input={"mode": "day", "date": "2026/04/23", "timezone": "Asia/Shanghai"},
|
||||
)
|
||||
|
||||
start_at, end_at, error = _resolve_read_range(request)
|
||||
result = await handle_calendar_list_day(request)
|
||||
|
||||
assert start_at is None
|
||||
assert end_at is None
|
||||
assert error == "date must be YYYY-MM-DD"
|
||||
assert result.ok is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "INVALID_ACTION_INPUT"
|
||||
assert result.error.details == {
|
||||
"missing_fields": [],
|
||||
"invalid_fields": ["day.date"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_read_range_requires_timezone_aware_datetimes() -> None:
|
||||
request = CliCommand(
|
||||
module="calendar",
|
||||
method="read",
|
||||
owner_id="u1",
|
||||
input={
|
||||
"mode": "range",
|
||||
"start_at": "2026-04-23T00:00:00",
|
||||
"end_at": "2026-04-24T00:00:00",
|
||||
},
|
||||
)
|
||||
|
||||
result = await handle_calendar_list_day(request)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "INVALID_ACTION_INPUT"
|
||||
assert sorted(result.error.details["invalid_fields"]) == ["range.end_at", "range.start_at"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_rejects_legacy_field_aliases_with_corrections() -> None:
|
||||
request = CliCommand(
|
||||
module="calendar",
|
||||
method="create",
|
||||
owner_id="u1",
|
||||
input={
|
||||
"title": "Project sync",
|
||||
"start_time": "2026-04-23T10:00:00+08:00",
|
||||
"end_time": "2026-04-23T11:00:00+08:00",
|
||||
"event_timezone": "Asia/Shanghai",
|
||||
},
|
||||
)
|
||||
|
||||
result = await handle_calendar_create_event(request)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "INVALID_ACTION_INPUT"
|
||||
assert result.error.details == {
|
||||
"missing_fields": ["start_at", "timezone"],
|
||||
"invalid_fields": ["end_time", "event_timezone", "start_time"],
|
||||
"alias_corrections": {
|
||||
"start_time": "start_at",
|
||||
"end_time": "end_at",
|
||||
"event_timezone": "timezone",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,18 +3,21 @@ from __future__ import annotations
|
||||
from core.agentscope.tools.cli.handlers import build_router
|
||||
|
||||
|
||||
def test_router_registers_only_new_canonical_subcommands() -> None:
|
||||
def test_router_registers_only_new_canonical_actions() -> None:
|
||||
router = build_router()
|
||||
|
||||
assert ("calendar", "create") in router.command_pairs
|
||||
assert ("calendar", "read") in router.command_pairs
|
||||
assert ("calendar", "update") in router.command_pairs
|
||||
assert ("calendar", "delete") in router.command_pairs
|
||||
assert ("calendar", "share") in router.command_pairs
|
||||
assert ("contacts", "read") in router.command_pairs
|
||||
assert ("memory", "update") in router.command_pairs
|
||||
assert ("calendar", "read") in router.module_methods
|
||||
assert ("calendar", "create") in router.module_methods
|
||||
assert ("calendar", "update") in router.module_methods
|
||||
assert ("calendar", "delete") in router.module_methods
|
||||
assert ("calendar", "share") in router.module_methods
|
||||
assert ("calendar", "accept_invite") in router.module_methods
|
||||
assert ("calendar", "reject_invite") in router.module_methods
|
||||
assert ("contacts", "read") in router.module_methods
|
||||
assert ("memory", "update") in router.module_methods
|
||||
|
||||
assert ("calendar", "write") not in router.command_pairs
|
||||
assert ("contacts", "lookup") not in router.command_pairs
|
||||
assert ("memory", "write") not in router.command_pairs
|
||||
assert ("memory", "forget") not in router.command_pairs
|
||||
assert ("calendar", "list_day") not in router.module_methods
|
||||
assert ("calendar", "get_event") not in router.module_methods
|
||||
assert ("contacts", "lookup") not in router.module_methods
|
||||
assert ("memory", "write") not in router.module_methods
|
||||
assert ("memory", "forget") not in router.module_methods
|
||||
|
||||
@@ -11,13 +11,13 @@ 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"]})
|
||||
return CliCommandResult(ok=True, module=request.module, method=request.method, data={"name": request.input["name"]})
|
||||
|
||||
router.register(command="test", subcommand="run", handler=mock_handler)
|
||||
router.register(module="test", method="run", handler=mock_handler)
|
||||
|
||||
assert ("test", "run") in router.command_pairs
|
||||
assert ("test", "run") in router.module_methods
|
||||
|
||||
result = await router.dispatch(CliCommand(command="test", subcommand="run", args={"name": "demo"}, owner_id="u1"))
|
||||
result = await router.dispatch(CliCommand(module="test", method="run", input={"name": "demo"}, owner_id="u1"))
|
||||
assert result.ok is True
|
||||
assert result.data == {"name": "demo"}
|
||||
|
||||
@@ -25,10 +25,10 @@ async def test_router_register_and_dispatch() -> None:
|
||||
@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"))
|
||||
result = await router.dispatch(CliCommand(module="unknown", method="run", input={}, owner_id="u1"))
|
||||
assert result.ok is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "UNKNOWN_COMMAND"
|
||||
assert result.error.code == "UNKNOWN_METHOD"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -39,9 +39,9 @@ async def test_router_handler_exception() -> None:
|
||||
del request
|
||||
raise ValueError("intentional error")
|
||||
|
||||
router.register(command="fail", subcommand="run", handler=failing_handler)
|
||||
router.register(module="fail", method="run", handler=failing_handler)
|
||||
|
||||
result = await router.dispatch(CliCommand(command="fail", subcommand="run", args={}, owner_id="u1"))
|
||||
result = await router.dispatch(CliCommand(module="fail", method="run", input={}, owner_id="u1"))
|
||||
assert result.ok is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "HANDLER_ERROR"
|
||||
@@ -51,12 +51,12 @@ def test_router_duplicate_register() -> None:
|
||||
router = CommandRouter()
|
||||
|
||||
async def handler1(request: CliCommand) -> CliCommandResult:
|
||||
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand)
|
||||
return CliCommandResult(ok=True, module=request.module, method=request.method)
|
||||
|
||||
async def handler2(request: CliCommand) -> CliCommandResult:
|
||||
return CliCommandResult(ok=True, command=request.command, subcommand=request.subcommand)
|
||||
return CliCommandResult(ok=True, module=request.module, method=request.method)
|
||||
|
||||
router.register(command="cmd", subcommand="one", handler=handler1)
|
||||
router.register(module="cmd", method="one", handler=handler1)
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
router.register(command="cmd", subcommand="one", handler=handler2)
|
||||
router.register(module="cmd", method="one", handler=handler2)
|
||||
|
||||
@@ -6,31 +6,53 @@ from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
|
||||
|
||||
def _make_tool_output(
|
||||
*,
|
||||
command: str,
|
||||
subcommand: str,
|
||||
module: str,
|
||||
method: 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": {}},
|
||||
tool_call_args={"module": module, "method": method, "input": {}},
|
||||
status=status,
|
||||
result={"command": command, "subcommand": subcommand, "data": data or {}},
|
||||
result={"module": module, "method": method, "data": data or {}},
|
||||
error=None,
|
||||
ui_hints=None,
|
||||
)
|
||||
|
||||
|
||||
def test_postprocess_calendar_read_has_ui_hints() -> None:
|
||||
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.SUCCESS, data={"total": 5, "items": []})
|
||||
output = _make_tool_output(
|
||||
module="calendar",
|
||||
method="read",
|
||||
status=ToolStatus.SUCCESS,
|
||||
data={"total": 5, "items": []},
|
||||
)
|
||||
processed = postprocess_tool_output(output)
|
||||
assert processed.ui_hints is not None
|
||||
assert processed.ui_hints["intent"] == "list"
|
||||
|
||||
|
||||
def test_postprocess_calendar_read_event_detail_has_ui_hints() -> None:
|
||||
output = _make_tool_output(
|
||||
module="calendar",
|
||||
method="read",
|
||||
status=ToolStatus.SUCCESS,
|
||||
data={"id": "evt_1", "title": "Project sync", "start_at": "2026-04-21T10:00:00+08:00"},
|
||||
)
|
||||
processed = postprocess_tool_output(output)
|
||||
assert processed.ui_hints is not None
|
||||
assert processed.ui_hints["title"] == "日程详情"
|
||||
|
||||
|
||||
def test_postprocess_calendar_create_partial() -> None:
|
||||
output = _make_tool_output(command="calendar", subcommand="create", status=ToolStatus.PARTIAL, data={"status": "partial", "success": 1, "failed": 1, "results": []})
|
||||
output = _make_tool_output(
|
||||
module="calendar",
|
||||
method="create",
|
||||
status=ToolStatus.PARTIAL,
|
||||
data={"status": "partial", "success": 1, "failed": 1, "results": []},
|
||||
)
|
||||
processed = postprocess_tool_output(output)
|
||||
assert processed.ui_hints is not None
|
||||
assert processed.ui_hints["intent"] == "status"
|
||||
@@ -39,8 +61,8 @@ def test_postprocess_calendar_create_partial() -> None:
|
||||
|
||||
def test_postprocess_calendar_share_has_ui_hints() -> None:
|
||||
output = _make_tool_output(
|
||||
command="calendar",
|
||||
subcommand="share",
|
||||
module="calendar",
|
||||
method="share",
|
||||
status=ToolStatus.SUCCESS,
|
||||
data={
|
||||
"status": "success",
|
||||
@@ -60,7 +82,12 @@ def test_postprocess_calendar_share_has_ui_hints() -> None:
|
||||
|
||||
|
||||
def test_postprocess_contacts_read_has_ui_hints() -> None:
|
||||
output = _make_tool_output(command="contacts", subcommand="read", status=ToolStatus.SUCCESS, data={"friends_count": 3, "friends": []})
|
||||
output = _make_tool_output(
|
||||
module="contacts",
|
||||
method="read",
|
||||
status=ToolStatus.SUCCESS,
|
||||
data={"friends_count": 3, "friends": []},
|
||||
)
|
||||
processed = postprocess_tool_output(output)
|
||||
assert processed.ui_hints is not None
|
||||
assert processed.ui_hints["intent"] == "list"
|
||||
@@ -69,8 +96,8 @@ def test_postprocess_contacts_read_has_ui_hints() -> None:
|
||||
|
||||
def test_postprocess_memory_update_has_ui_hints() -> None:
|
||||
output = _make_tool_output(
|
||||
command="memory",
|
||||
subcommand="update",
|
||||
module="memory",
|
||||
method="update",
|
||||
status=ToolStatus.SUCCESS,
|
||||
data={
|
||||
"status": "success",
|
||||
@@ -95,19 +122,19 @@ def test_postprocess_memory_update_has_ui_hints() -> None:
|
||||
|
||||
|
||||
def test_postprocess_failure_no_ui_hints() -> None:
|
||||
output = _make_tool_output(command="calendar", subcommand="read", status=ToolStatus.FAILURE, data=None)
|
||||
output = _make_tool_output(module="calendar", method="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"})
|
||||
output = _make_tool_output(module="unknown", method="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 = _make_tool_output(module="calendar", method="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"
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.skill_session import SkillSessionState
|
||||
from core.agentscope.tools.toolkit import build_toolkit
|
||||
from schemas.agent.skill_config import SkillName
|
||||
|
||||
@@ -48,8 +49,22 @@ def test_build_toolkit_registers_project_cli() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_build_toolkit_uses_custom_agent_skill_prompt_contract() -> None:
|
||||
toolkit = build_toolkit(enabled_skill_names={"calendar"})
|
||||
|
||||
prompt = toolkit.get_agent_skill_prompt()
|
||||
|
||||
assert prompt is not None
|
||||
assert "The entries below are skill indexes, not full execution instructions." in prompt
|
||||
assert 'file_path="calendar/SKILL.md"' in prompt
|
||||
assert "/home/" not in prompt
|
||||
|
||||
|
||||
def test_view_skill_file_rejects_path_outside_enabled_skill_dirs() -> None:
|
||||
wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"})
|
||||
wrapper = make_view_skill_file_wrapper(
|
||||
enabled_skill_names={"calendar"},
|
||||
skill_session=SkillSessionState(),
|
||||
)
|
||||
|
||||
response = asyncio.run(
|
||||
wrapper(file_path="/tmp/not-allowed.txt", ranges=None),
|
||||
@@ -62,10 +77,48 @@ def test_view_skill_file_rejects_path_outside_enabled_skill_dirs() -> None:
|
||||
|
||||
|
||||
def test_view_skill_file_reads_enabled_skill_file() -> None:
|
||||
wrapper = make_view_skill_file_wrapper(enabled_skill_names={"calendar"})
|
||||
skill_session = SkillSessionState()
|
||||
wrapper = make_view_skill_file_wrapper(
|
||||
enabled_skill_names={"calendar"},
|
||||
skill_session=skill_session,
|
||||
)
|
||||
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
|
||||
assert skill_session.has_read(skill_name="calendar") is True
|
||||
|
||||
|
||||
def test_view_skill_file_reads_calendar_action_card() -> None:
|
||||
skill_session = SkillSessionState()
|
||||
wrapper = make_view_skill_file_wrapper(
|
||||
enabled_skill_names={"calendar"},
|
||||
skill_session=skill_session,
|
||||
)
|
||||
response = asyncio.run(
|
||||
wrapper(file_path="calendar/actions/get_event.md", ranges=[1, 20])
|
||||
)
|
||||
|
||||
assert response.content
|
||||
block = response.content[0]
|
||||
text = block["text"] if isinstance(block, dict) else block.text
|
||||
assert "get_event" in text
|
||||
assert '"action": "get_event"' in text
|
||||
assert skill_session.has_read(skill_name="calendar") is True
|
||||
|
||||
|
||||
def test_view_skill_file_rejects_action_card_for_disabled_skill() -> None:
|
||||
wrapper = make_view_skill_file_wrapper(
|
||||
enabled_skill_names={"contacts"},
|
||||
skill_session=SkillSessionState(),
|
||||
)
|
||||
response = asyncio.run(
|
||||
wrapper(file_path="calendar/actions/get_event.md", ranges=[1, 20])
|
||||
)
|
||||
|
||||
assert response.content
|
||||
block = response.content[0]
|
||||
text = block["text"] if isinstance(block, dict) else block.text
|
||||
assert "ACCESS_DENIED" in text
|
||||
|
||||
Reference in New Issue
Block a user