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:
qzl
2026-04-24 13:24:13 +08:00
parent ab526af2c4
commit d060962a5f
62 changed files with 4802 additions and 805 deletions
@@ -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