refactor: clean CLI taxonomy — canonical subcommands, merged memory.update, no aliases

- calendar: split write → create/read/update/delete/share
- contacts: rename lookup → read
- memory: merge write+forget → update (unified action field in operations)
- Remove all alias/normalization logic from adapter and handlers
- Update tool_postprocessor ui_hints builders to canonical keys
- Remove frontend legacy TOOL_CALL_START/ARGS/END events and ToolCallItem
- Update SKILL.md files and protocol docs
- Update tests and settings screens
This commit is contained in:
qzl
2026-04-23 12:12:41 +08:00
parent 91077a933d
commit 19e273a9e6
48 changed files with 1578 additions and 811 deletions
+2 -1
View File
@@ -9,6 +9,7 @@ from core.agentscope.caches.context_messages_cache import (
create_context_messages_cache,
)
from core.agentscope.events.persistence import MessageRepository, SessionRepository
from core.agentscope.utils.parsing import project_tool_result_text
from core.logging import get_logger
from schemas.agent.forwarded_props import RuntimeMode
from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus
@@ -339,7 +340,7 @@ class SqlAlchemyEventStore:
)
return
content = tool_output.result
content = project_tool_result_text(tool_output.result)
locked_session = await session_repo.lock_session_for_update(
session_id=session_id
+68 -49
View File
@@ -22,6 +22,11 @@ from core.agentscope.utils import (
finalize_json_response,
patch_agentscope_json_repair_compat,
)
from core.auth.credential_issuer import create_credential_issuer
from core.auth.tool_credential_context import (
set_tool_credential,
reset_tool_credential,
)
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from models.llm import Llm
@@ -37,7 +42,7 @@ from schemas.agent.runtime_models import (
RouterAgentOutput,
WorkerAgentOutputLite,
)
from schemas.agent.skill_config import SkillName
from schemas.agent.skill_config import ProjectCliCommand, SkillName
from schemas.agent.system_agent import (
AgentType,
SystemAgentLLMConfig,
@@ -107,7 +112,8 @@ class AgentScopeRunner:
agent_type=AgentType.WORKER,
)
worker_toolkit = self._build_toolkit(
enabled_skills=runtime_config.enabled_skills
enabled_skills=runtime_config.enabled_skills,
allowed_commands=runtime_config.allowed_commands,
)
router_output = await self._execute_router_step(
@@ -174,10 +180,13 @@ class AgentScopeRunner:
self,
*,
enabled_skills: list[SkillName],
allowed_commands: list[ProjectCliCommand],
) -> Any:
enabled_skill_names = {str(skill.value) for skill in enabled_skills}
allowed_command_names = {str(command.value) for command in allowed_commands}
return build_toolkit(
enabled_skill_names=enabled_skill_names if enabled_skill_names else None
enabled_skill_names=enabled_skill_names if enabled_skill_names else None,
allowed_commands=allowed_command_names if allowed_command_names else None,
)
async def _load_stage_config(
@@ -388,56 +397,66 @@ class AgentScopeRunner:
work_memory: WorkProfileContent | None,
requires_tool_evidence: bool = False,
) -> StageExecutionResult:
tracking_model = self._build_model(stage_config=stage_config)
emitter = PipelineStageEmitter(
pipeline=pipeline,
session_id=run_input.thread_id,
run_id=run_input.run_id,
stage=stage_config.agent_type.value,
runtime_mode=runtime_mode.value,
emit_text_events=True,
emit_tool_events=True,
issuer = create_credential_issuer()
credential = issuer.issue(
owner_id=str(user_context.id),
mode=runtime_mode.value,
)
agent = self._build_agent(
agent_name=stage_config.agent_type.value,
system_prompt=build_system_prompt(
agent_type=stage_config.agent_type,
llm_config=stage_config.llm_config,
user_context=user_context,
now_utc=datetime.now(timezone.utc),
runtime_client_time=runtime_client_time,
extra_context=stage_config.extra_context,
work_memory=work_memory,
),
toolkit=toolkit,
model=tracking_model,
emitter=emitter,
force_tool_on_first_reasoning=requires_tool_evidence,
)
async with self._active_agent_lock:
self._active_agent = agent
credential_token = set_tool_credential(credential)
try:
response_msg = await agent.reply_json(
input_messages, output_model=worker_output_model
tracking_model = self._build_model(stage_config=stage_config)
emitter = PipelineStageEmitter(
pipeline=pipeline,
session_id=run_input.thread_id,
run_id=run_input.run_id,
stage=stage_config.agent_type.value,
runtime_mode=runtime_mode.value,
emit_text_events=True,
emit_tool_events=True,
)
agent = self._build_agent(
agent_name=stage_config.agent_type.value,
system_prompt=build_system_prompt(
agent_type=stage_config.agent_type,
llm_config=stage_config.llm_config,
user_context=user_context,
now_utc=datetime.now(timezone.utc),
runtime_client_time=runtime_client_time,
extra_context=stage_config.extra_context,
work_memory=work_memory,
),
toolkit=toolkit,
model=tracking_model,
emitter=emitter,
force_tool_on_first_reasoning=requires_tool_evidence,
)
async with self._active_agent_lock:
self._active_agent = agent
try:
response_msg = await agent.reply_json(
input_messages, output_model=worker_output_model
)
finally:
async with self._active_agent_lock:
if self._active_agent is agent:
self._active_agent = None
worker_payload = worker_output_model.model_validate(response_msg.metadata or {})
response_metadata = self._llm_pricing_service.build_usage_metadata(
model=stage_config.model_code,
usage_summary=tracking_model.usage_summary(),
)
await emitter.emit_final_text_end(
worker_output=worker_payload.model_dump(mode="json", exclude_none=True),
response_metadata=response_metadata,
)
return StageExecutionResult(
message=response_msg,
payload=worker_payload.model_dump(mode="json", exclude_none=True),
response_metadata=response_metadata,
)
finally:
async with self._active_agent_lock:
if self._active_agent is agent:
self._active_agent = None
worker_payload = worker_output_model.model_validate(response_msg.metadata or {})
response_metadata = self._llm_pricing_service.build_usage_metadata(
model=stage_config.model_code,
usage_summary=tracking_model.usage_summary(),
)
await emitter.emit_final_text_end(
worker_output=worker_payload.model_dump(mode="json", exclude_none=True),
response_metadata=response_metadata,
)
return StageExecutionResult(
message=response_msg,
payload=worker_payload.model_dump(mode="json", exclude_none=True),
response_metadata=response_metadata,
)
reset_tool_credential(credential_token)
def _build_worker_input_messages(
self,
@@ -63,6 +63,12 @@ async def invoke_cli_tool(
if not isinstance(args, dict):
args = {}
tool_call_args = {
**tool_call_args,
"subcommand": subcommand,
"args": args,
}
if tool_name != "project_cli":
return _build_error(
tool_name=tool_name,
@@ -1,7 +1,9 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
from core.agentscope.tools.utils.calendar_domain import (
@@ -12,6 +14,7 @@ from core.agentscope.tools.utils.calendar_domain import (
parse_iso_datetime,
schedule_event_to_dict,
)
from schemas.agent.runtime_models import ErrorInfo
from schemas.enums import ScheduleItemStatus
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
@@ -24,83 +27,181 @@ from v1.schedule_items.schemas import (
async def handle_calendar_read(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
start_at = str(request.args.get("start_at", ""))
end_at = str(request.args.get("end_at", ""))
parsed_start = parse_iso_datetime(start_at)
parsed_end = parse_iso_datetime(end_at)
parsed_start, parsed_end, read_error = _resolve_read_range(request)
if read_error is not None:
return _fail(request=request, code="INVALID_ARGUMENT", message=read_error)
if parsed_start is None or parsed_end is None:
return _fail(request=request, code="INVALID_ARGUMENT", message="start_at and end_at are required")
return _fail(
request=request,
code="INVALID_ARGUMENT",
message="start_at and end_at are required",
)
if parsed_start >= parsed_end:
return _fail(request=request, code="INVALID_ARGUMENT", message="start_at must be before end_at")
return _fail(
request=request,
code="INVALID_ARGUMENT",
message="start_at must be before end_at",
)
async with AsyncSessionLocal() as session:
service = create_schedule_service(session, UUID(request.owner_id))
list_request = ScheduleItemListRequest(start_at=parsed_start, end_at=parsed_end)
items = await service.list_by_date_range(list_request)
event_items = [schedule_event_to_dict(item) for item in items]
return CliCommandResult(ok=True, command="calendar", subcommand="read", data={"total": len(event_items), "items": event_items})
return CliCommandResult(
ok=True,
command="calendar",
subcommand="read",
data={"total": len(event_items), "items": event_items},
)
async def handle_calendar_write(request: CliCommand) -> CliCommandResult:
async def handle_calendar_create(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
operations = request.args.get("operations")
if not isinstance(operations, list):
operations = []
async with AsyncSessionLocal() as session:
service = create_schedule_service(session, UUID(request.owner_id))
success_count = 0
failed_count = 0
success_ids: list[str] = []
result_items: list[dict[str, Any]] = []
for op in operations:
action = op.get("action")
try:
if action == "create":
res = await _create_event(service, op)
success_count += 1
success_ids.append(res["eventId"])
result_items.append(res)
elif action == "update":
res = await _update_event(service, op)
success_count += 1
success_ids.append(res["eventId"])
result_items.append(res)
elif action == "delete":
event_id = op.get("event_id")
if not event_id:
raise ValueError("delete requires event_id")
await service.delete(UUID(event_id))
success_count += 1
success_ids.append(event_id)
result_items.append({"status": "success", "eventId": event_id})
else:
raise ValueError(f"unknown action: {action}")
except Exception as exc:
code, message, _ = map_calendar_exception(exc)
failed_count += 1
result_items.append({
try:
result_item = await _create_event(service, request.args)
event_id = str(result_item.get("eventId") or "")
return CliCommandResult(
ok=True,
command=request.command,
subcommand=request.subcommand,
data={
"status": "success",
"success": 1,
"failed": 0,
"ids": [event_id] if event_id else [],
"results": [result_item],
},
)
except Exception as exc:
code, message, retryable = map_calendar_exception(exc)
return CliCommandResult(
ok=False,
command=request.command,
subcommand=request.subcommand,
data={
"status": "failure",
"eventId": op.get("event_id"),
"code": code,
"message": message,
})
"success": 0,
"failed": 1,
"ids": [],
"results": [
{
"action": "create",
"status": "failure",
"eventId": "",
"code": code,
"message": message,
}
],
},
error=ErrorInfo(code=code, message=message, retryable=retryable),
)
status = _batch_status(success_count, failed_count)
return CliCommandResult(
ok=status != "failure",
command=request.command,
subcommand=request.subcommand,
data={
"status": status,
"success": success_count,
"failed": failed_count,
"ids": success_ids,
"results": result_items,
},
)
async def handle_calendar_update(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
service = create_schedule_service(session, UUID(request.owner_id))
event_id = str(request.args.get("event_id") or "").strip()
try:
result_item = await _update_event(service, request.args)
event_id = str(result_item.get("eventId") or event_id)
return CliCommandResult(
ok=True,
command=request.command,
subcommand=request.subcommand,
data={
"status": "success",
"success": 1,
"failed": 0,
"ids": [event_id] if event_id else [],
"results": [result_item],
},
)
except Exception as exc:
code, message, retryable = map_calendar_exception(exc)
return CliCommandResult(
ok=False,
command=request.command,
subcommand=request.subcommand,
data={
"status": "failure",
"success": 0,
"failed": 1,
"ids": [],
"results": [
{
"action": "update",
"status": "failure",
"eventId": event_id,
"code": code,
"message": message,
}
],
},
error=ErrorInfo(code=code, message=message, retryable=retryable),
)
async def handle_calendar_delete(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
service = create_schedule_service(session, UUID(request.owner_id))
event_id = str(request.args.get("event_id") or "").strip()
if not event_id:
return _fail(
request=request,
code="INVALID_ARGUMENT",
message="event_id is required",
)
try:
await service.delete(UUID(event_id))
return CliCommandResult(
ok=True,
command=request.command,
subcommand=request.subcommand,
data={
"status": "success",
"success": 1,
"failed": 0,
"ids": [event_id],
"results": [
{
"action": "delete",
"status": "success",
"eventId": event_id,
}
],
},
)
except Exception as exc:
code, message, retryable = map_calendar_exception(exc)
return CliCommandResult(
ok=False,
command=request.command,
subcommand=request.subcommand,
data={
"status": "failure",
"success": 0,
"failed": 1,
"ids": [],
"results": [
{
"action": "delete",
"status": "failure",
"eventId": event_id,
"code": code,
"message": message,
}
],
},
error=ErrorInfo(code=code, message=message, retryable=retryable),
)
async def handle_calendar_share(request: CliCommand) -> CliCommandResult:
@@ -121,7 +222,14 @@ async def handle_calendar_share(request: CliCommand) -> CliCommandResult:
raw_phone = inv.get("phone", "").strip()
normalized_phone = _normalize_phone(raw_phone)
if not normalized_phone:
result_items.append({"phone": raw_phone, "status": "failure", "code": "INVALID_ARGUMENT", "message": "invalid phone"})
result_items.append(
{
"phone": raw_phone,
"status": "failure",
"code": "INVALID_ARGUMENT",
"message": "invalid phone",
}
)
continue
permission = {
"permission_view": inv.get("permission_view", True),
@@ -129,12 +237,22 @@ async def handle_calendar_share(request: CliCommand) -> CliCommandResult:
"permission_invite": inv.get("permission_invite", False),
}
try:
await service.share(target_uuid, ScheduleItemShareRequest(phone=normalized_phone, **permission))
await service.share(
target_uuid,
ScheduleItemShareRequest(phone=normalized_phone, **permission),
)
invited.append(normalized_phone)
result_items.append({"phone": normalized_phone, "status": "success"})
except Exception as exc:
code, message, _ = map_calendar_exception(exc)
result_items.append({"phone": normalized_phone, "status": "failure", "code": code, "message": message})
result_items.append(
{
"phone": normalized_phone,
"status": "failure",
"code": code,
"message": message,
}
)
failure_count = len([r for r in result_items if r["status"] == "failure"])
success_count = len(invited)
@@ -152,64 +270,101 @@ async def handle_calendar_share(request: CliCommand) -> CliCommandResult:
)
async def _create_event(service: Any, op: dict[str, Any]) -> dict[str, Any]:
start_at = op.get("start_at")
if not start_at:
async def _create_event(service: Any, args: dict[str, Any]) -> dict[str, Any]:
start_at = args.get("start_at")
if not isinstance(start_at, str) or not start_at.strip():
raise ValueError("create requires start_at")
event_timezone = op.get("event_timezone")
if not event_timezone:
event_timezone = args.get("event_timezone")
if not isinstance(event_timezone, str) or not event_timezone.strip():
raise ValueError("create requires event_timezone")
parsed_start = parse_iso_datetime(start_at)
if parsed_start is None:
raise ValueError("invalid start_at")
end_at = op.get("end_at")
parsed_end = parse_iso_datetime(end_at) if end_at else None
parsed_end = None
end_at = args.get("end_at")
if isinstance(end_at, str) and end_at.strip():
parsed_end = parse_iso_datetime(end_at)
if parsed_end is None:
raise ValueError("invalid end_at")
created = await service.create_agent_generated(
ScheduleItemCreateRequest(
title=(op.get("title") or "new event").strip(),
description=op.get("description", "").strip() or None,
title=str(args.get("title") or "new event").strip(),
description=(str(args.get("description") or "").strip() or None),
start_at=parsed_start,
end_at=parsed_end,
timezone=event_timezone.strip(),
metadata=build_schedule_metadata(
op.get("location"),
op.get("color"),
op.get("reminder_minutes"),
args.get("location"),
args.get("color"),
args.get("reminder_minutes"),
),
)
)
return {"status": "success", "eventId": str(created.id)}
return {"action": "create", "status": "success", "eventId": str(created.id)}
async def _update_event(service: Any, op: dict[str, Any]) -> dict[str, Any]:
event_id = op.get("event_id")
if not event_id:
async def _update_event(service: Any, args: dict[str, Any]) -> dict[str, Any]:
event_id = args.get("event_id")
if not isinstance(event_id, str) or not event_id.strip():
raise ValueError("update requires event_id")
update_data: dict[str, Any] = {}
if "title" in op:
update_data["title"] = op["title"].strip()
if "description" in op:
update_data["description"] = op["description"].strip()
if "start_at" in op:
update_data["start_at"] = parse_iso_datetime(op["start_at"])
if "end_at" in op:
update_data["end_at"] = parse_iso_datetime(op["end_at"])
if "event_timezone" in op:
update_data["timezone"] = op["event_timezone"].strip()
if "status" in op:
update_data["status"] = ScheduleItemStatus(op["status"])
if any(k in op for k in ("location", "color", "reminder_minutes")):
if "title" in args:
update_data["title"] = str(args.get("title") or "").strip()
if "description" in args:
update_data["description"] = str(args.get("description") or "").strip()
if "start_at" in args:
start_value = args.get("start_at")
if not isinstance(start_value, str) or not start_value.strip():
raise ValueError("start_at must be non-empty string")
parsed_start = parse_iso_datetime(start_value)
if parsed_start is None:
raise ValueError("invalid start_at")
update_data["start_at"] = parsed_start
if "end_at" in args:
end_value = args.get("end_at")
if end_value in (None, ""):
update_data["end_at"] = None
elif isinstance(end_value, str):
parsed_end = parse_iso_datetime(end_value)
if parsed_end is None:
raise ValueError("invalid end_at")
update_data["end_at"] = parsed_end
else:
raise ValueError("end_at must be string or null")
if "event_timezone" in args:
timezone_value = args.get("event_timezone")
if not isinstance(timezone_value, str) or not timezone_value.strip():
raise ValueError("event_timezone must be non-empty string")
update_data["timezone"] = timezone_value.strip()
if "status" in args:
update_data["status"] = ScheduleItemStatus(str(args.get("status")))
if any(key in args for key in ("location", "color", "reminder_minutes")):
existing = await service.get_by_id(UUID(event_id))
update_data["metadata"] = merge_schedule_metadata_for_update(
existing_metadata=existing.metadata,
location=op.get("location"),
color=op.get("color"),
reminder_minutes=op.get("reminder_minutes"),
location=args.get("location"),
color=args.get("color"),
reminder_minutes=args.get("reminder_minutes"),
)
if not update_data:
raise ValueError("update requires at least one mutable field")
changed_fields = sorted(update_data.keys())
updated = await service.update(UUID(event_id), ScheduleItemUpdateRequest.model_validate(update_data))
return {"status": "success", "eventId": str(updated.id), "changedFields": changed_fields}
updated = await service.update(
UUID(event_id),
ScheduleItemUpdateRequest.model_validate(update_data),
)
return {
"action": "update",
"status": "success",
"eventId": str(updated.id),
"changedFields": changed_fields,
}
def _normalize_phone(raw: str) -> str:
@@ -222,7 +377,12 @@ def _normalize_phone(raw: str) -> str:
phone = f"+{phone}"
elif phone.startswith("1") and phone.isdigit():
phone = f"+86{phone}"
if len(phone) != 14 or not phone.startswith("+861") or not phone[1:].isdigit() or phone[4] not in "3456789":
if (
len(phone) != 14
or not phone.startswith("+861")
or not phone[1:].isdigit()
or phone[4] not in "3456789"
):
return ""
return phone
@@ -235,9 +395,52 @@ def _batch_status(success: int, failed: int) -> str:
return "partial"
def _fail(*, request: CliCommand, code: str, message: str) -> CliCommandResult:
from schemas.agent.runtime_models import ErrorInfo
def _resolve_read_range(
request: CliCommand,
) -> tuple[datetime | None, datetime | None, str | None]:
start_at = str(request.args.get("start_at", "")).strip()
end_at = str(request.args.get("end_at", "")).strip()
if start_at and end_at:
try:
return parse_iso_datetime(start_at), parse_iso_datetime(end_at), None
except ValueError as exc:
return None, None, str(exc)
raw_date = str(request.args.get("date", "")).strip()
if not raw_date:
return None, None, None
timezone_name = (
str(request.args.get("timezone", "Asia/Shanghai")).strip() or "Asia/Shanghai"
)
try:
zone = ZoneInfo(timezone_name)
except Exception:
return None, None, "timezone is invalid"
try:
target_date = date.fromisoformat(raw_date)
except ValueError:
return None, None, "date must be YYYY-MM-DD"
start_local = datetime(
year=target_date.year,
month=target_date.month,
day=target_date.day,
hour=0,
minute=0,
second=0,
tzinfo=zone,
)
end_local = start_local + timedelta(days=1)
return (
parse_iso_datetime(start_local.isoformat()),
parse_iso_datetime(end_local.isoformat()),
None,
)
def _fail(*, request: CliCommand, code: str, message: str) -> CliCommandResult:
return CliCommandResult(
ok=False,
command=request.command,
@@ -13,7 +13,7 @@ from v1.auth.gateway import SupabaseAuthGateway
from v1.users.contact_resolver import resolve_contacts_by_user_ids
async def handle_contacts_lookup(request: CliCommand) -> CliCommandResult:
async def handle_contacts_read(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
@@ -9,127 +9,130 @@ from core.agentscope.tools.utils.memory_domain import (
create_memories_service,
map_memory_exception,
)
from schemas.agent.runtime_models import ErrorInfo
from schemas.enums import MemoryType
from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent
async def handle_memory_write(request: CliCommand) -> CliCommandResult:
async def handle_memory_update(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
operations = request.args.get("operations")
if not isinstance(operations, list):
operations = []
if not isinstance(operations, list) or not operations:
return _invalid_argument(
request=request,
message="operations must be a non-empty list",
)
async with AsyncSessionLocal() as session:
service = create_memories_service(session=session, owner_id=UUID(request.owner_id))
success_count = 0
failed_count = 0
updated_types: list[str] = []
forgotten_total = 0
failed_ops: list[dict[str, Any]] = []
result_items: list[dict[str, Any]] = []
for idx, op in enumerate(operations):
memory_type = MemoryType(op.get("memory_type", "user"))
if not isinstance(op, dict):
failed_count += 1
failed_ops.append(
{
"code": "INVALID_ARGUMENT",
"message": "operation item must be object",
"retryable": False,
}
)
result_items.append(
{
"idx": idx,
"memoryType": "unknown",
"action": "invalid",
"status": "failure",
"code": "INVALID_ARGUMENT",
}
)
continue
action = str(op.get("action") or "").strip().lower()
if action not in {"update", "delete"}:
failed_count += 1
failed_ops.append(
{
"code": "INVALID_ARGUMENT",
"message": "action must be update or delete",
"retryable": False,
}
)
result_items.append(
{
"idx": idx,
"memoryType": str(op.get("memory_type") or "unknown"),
"action": action or "invalid",
"status": "failure",
"code": "INVALID_ARGUMENT",
}
)
continue
memory_type = MemoryType(str(op.get("memory_type") or "user"))
try:
existing = await service.get_memory_model(memory_type=memory_type)
if memory_type == MemoryType.USER:
content_data = op.get("user_content", {})
base = UserMemoryContent.model_validate(existing.content) if existing else UserMemoryContent()
patch = UserMemoryContent.model_validate(content_data)
merged = _deep_merge_dict(base.model_dump(), patch.model_dump(exclude_unset=True))
validated = UserMemoryContent.model_validate(merged)
updated = await service.update_user_memory(content=validated)
if action == "update":
result = await _apply_update_operation(
service=service,
memory_type=memory_type,
op=op,
)
else:
content_data = op.get("work_content", {})
base = WorkProfileContent.model_validate(existing.content) if existing else WorkProfileContent()
patch = WorkProfileContent.model_validate(content_data)
merged = _deep_merge_dict(base.model_dump(), patch.model_dump(exclude_unset=True))
validated = WorkProfileContent.model_validate(merged)
updated = await service.update_work_memory(content=validated)
result = await _apply_delete_operation(
service=service,
memory_type=memory_type,
op=op,
)
success_count += 1
updated_types.append(memory_type.value)
memory_id = str(getattr(updated, "id", "") or (getattr(existing, "id", "") if existing else "") or "")
result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "success", "memoryId": memory_id})
forgotten_total += int(result.get("forgotten") or 0)
result_items.append(
{
"idx": idx,
"memoryType": memory_type.value,
"action": action,
"status": "success",
**result,
}
)
except Exception as exc:
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_ops.append({"memory_type": memory_type.value, "code": code, "message": message, "retryable": retryable})
result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "failure", "code": code})
failed_ops.append(
{
"memory_type": memory_type.value,
"code": code,
"message": message,
"retryable": retryable,
}
)
result_items.append(
{
"idx": idx,
"memoryType": memory_type.value,
"action": action,
"status": "failure",
"code": code,
}
)
status = _batch_status(success_count, failed_count)
error = None
error_info = None
if failed_ops:
first = failed_ops[0]
error = {"code": first.get("code", "MEMORY_BATCH_FAILED"), "message": first.get("message", "memory batch write failed"), "retryable": bool(first.get("retryable"))}
error_info = map_memory_error(error) if isinstance(error, dict) else None
return CliCommandResult(
ok=status != "failure",
command=request.command,
subcommand=request.subcommand,
data={
"status": status,
"success": success_count,
"failed": failed_count,
"updated_types": updated_types,
"results": result_items,
},
error=error_info,
)
async def handle_memory_forget(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
operations = request.args.get("operations")
if not isinstance(operations, list):
operations = []
async with AsyncSessionLocal() as session:
service = create_memories_service(session=session, owner_id=UUID(request.owner_id))
success_count = 0
failed_count = 0
forgotten_total = 0
processed_types: list[str] = []
failed_ops: list[dict[str, Any]] = []
result_items: list[dict[str, Any]] = []
for idx, op in enumerate(operations):
memory_type = MemoryType(op.get("memory_type", "user"))
forget_paths = op.get("forget_paths", [])
try:
existing = await service.get_memory_model(memory_type=memory_type)
if existing is None:
success_count += 1
processed_types.append(memory_type.value)
result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "success", "forgotten": 0, "memoryId": ""})
continue
if memory_type == MemoryType.USER:
base = UserMemoryContent.model_validate(existing.content)
updated_dict, removed = _remove_content_paths(base.model_dump(), forget_paths)
validated = UserMemoryContent.model_validate(updated_dict)
await service.update_user_memory(content=validated)
else:
base = WorkProfileContent.model_validate(existing.content)
updated_dict, removed = _remove_content_paths(base.model_dump(), forget_paths)
validated = WorkProfileContent.model_validate(updated_dict)
await service.update_work_memory(content=validated)
forgotten_total += len(removed)
success_count += 1
processed_types.append(memory_type.value)
result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "success", "forgotten": len(removed), "memoryId": str(getattr(existing, "id", "") or "")})
except Exception as exc:
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_ops.append({"memory_type": memory_type.value, "code": code, "message": message, "retryable": retryable})
result_items.append({"idx": idx, "memoryType": memory_type.value, "status": "failure", "code": code})
status = _batch_status(success_count, failed_count)
error = None
if failed_ops:
first = failed_ops[0]
error = {"code": first.get("code", "MEMORY_BATCH_FAILED"), "message": first.get("message", "memory batch forget failed"), "retryable": bool(first.get("retryable"))}
error_info = map_memory_error(error) if isinstance(error, dict) else None
error_info = ErrorInfo(
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
message=str(first.get("message") or "memory batch update failed"),
retryable=bool(first.get("retryable")),
)
return CliCommandResult(
ok=status != "failure",
command=request.command,
@@ -138,21 +141,104 @@ async def handle_memory_forget(request: CliCommand) -> CliCommandResult:
"status": status,
"success": success_count,
"failed": failed_count,
"updated_types": sorted(set(updated_types)),
"forgotten": forgotten_total,
"processed_types": processed_types,
"results": result_items,
},
error=error_info,
)
def map_memory_error(error: dict[str, Any]):
from schemas.agent.runtime_models import ErrorInfo
async def _apply_update_operation(
*,
service: Any,
memory_type: MemoryType,
op: dict[str, Any],
) -> dict[str, Any]:
existing = await service.get_memory_model(memory_type=memory_type)
if memory_type == MemoryType.USER:
content_data = op.get("user_content")
if not isinstance(content_data, dict):
raise ValueError("update action for user memory requires user_content")
base = (
UserMemoryContent.model_validate(existing.content)
if existing
else UserMemoryContent()
)
patch = UserMemoryContent.model_validate(content_data)
merged = _deep_merge_dict(
base.model_dump(),
patch.model_dump(exclude_unset=True),
)
validated = UserMemoryContent.model_validate(merged)
updated = await service.update_user_memory(content=validated)
else:
content_data = op.get("work_content")
if not isinstance(content_data, dict):
raise ValueError("update action for work memory requires work_content")
base = (
WorkProfileContent.model_validate(existing.content)
if existing
else WorkProfileContent()
)
patch = WorkProfileContent.model_validate(content_data)
merged = _deep_merge_dict(
base.model_dump(),
patch.model_dump(exclude_unset=True),
)
validated = WorkProfileContent.model_validate(merged)
updated = await service.update_work_memory(content=validated)
return ErrorInfo(
code=str(error.get("code", "MEMORY_BATCH_FAILED")),
message=str(error.get("message", "memory operation failed")),
retryable=bool(error.get("retryable", False)),
memory_id = str(
getattr(updated, "id", "")
or (getattr(existing, "id", "") if existing else "")
or ""
)
return {"memoryId": memory_id, "forgotten": 0}
async def _apply_delete_operation(
*,
service: Any,
memory_type: MemoryType,
op: dict[str, Any],
) -> dict[str, Any]:
forget_paths_raw = op.get("forget_paths")
if not isinstance(forget_paths_raw, list) or not forget_paths_raw:
raise ValueError("delete action requires non-empty forget_paths")
forget_paths = [
str(path).strip() for path in forget_paths_raw if str(path).strip()
]
if not forget_paths:
raise ValueError("delete action requires non-empty forget_paths")
existing = await service.get_memory_model(memory_type=memory_type)
if existing is None:
return {"memoryId": "", "forgotten": 0}
if memory_type == MemoryType.USER:
base = UserMemoryContent.model_validate(existing.content)
updated_dict, removed = _remove_content_paths(base.model_dump(), forget_paths)
validated = UserMemoryContent.model_validate(updated_dict)
await service.update_user_memory(content=validated)
else:
base = WorkProfileContent.model_validate(existing.content)
updated_dict, removed = _remove_content_paths(base.model_dump(), forget_paths)
validated = WorkProfileContent.model_validate(updated_dict)
await service.update_work_memory(content=validated)
return {
"memoryId": str(getattr(existing, "id", "") or ""),
"forgotten": len(removed),
}
def _invalid_argument(*, request: CliCommand, message: str) -> CliCommandResult:
return CliCommandResult(
ok=False,
command=request.command,
subcommand=request.subcommand,
error=ErrorInfo(code="INVALID_ARGUMENT", message=message, retryable=False),
)
@@ -1,24 +1,24 @@
from __future__ import annotations
from core.agentscope.tools.cli.handler_calendar import (
handle_calendar_create,
handle_calendar_delete,
handle_calendar_read,
handle_calendar_share,
handle_calendar_write,
)
from core.agentscope.tools.cli.handler_contacts import handle_contacts_lookup
from core.agentscope.tools.cli.handler_memory import (
handle_memory_forget,
handle_memory_write,
handle_calendar_update,
)
from core.agentscope.tools.cli.handler_contacts import handle_contacts_read
from core.agentscope.tools.cli.handler_memory import handle_memory_update
from core.agentscope.tools.cli.router import CommandRouter
def build_router() -> CommandRouter:
router = CommandRouter()
router.register(command="calendar", subcommand="create", handler=handle_calendar_create)
router.register(command="calendar", subcommand="read", handler=handle_calendar_read)
router.register(command="calendar", subcommand="write", handler=handle_calendar_write)
router.register(command="calendar", subcommand="update", handler=handle_calendar_update)
router.register(command="calendar", subcommand="delete", handler=handle_calendar_delete)
router.register(command="calendar", subcommand="share", handler=handle_calendar_share)
router.register(command="contacts", subcommand="lookup", handler=handle_contacts_lookup)
router.register(command="memory", subcommand="write", handler=handle_memory_write)
router.register(command="memory", subcommand="forget", handler=handle_memory_forget)
router.register(command="contacts", subcommand="read", handler=handle_contacts_read)
router.register(command="memory", subcommand="update", handler=handle_memory_update)
return router
@@ -31,7 +31,7 @@ def make_project_cli_wrapper(*, allowed_commands: set[str]) -> Any:
Args:
command: The command to execute (calendar, contacts, memory).
subcommand: The subcommand for the operation (read, write, lookup, etc.).
subcommand: The subcommand for the operation (calendar: create/read/update/delete/share; contacts: read; memory: update).
args: Arguments for the command as a JSON object.
Returns:
@@ -41,24 +41,51 @@ Call `project_cli` with:
Use this whenever the user asks what is scheduled, free, upcoming, or happening in a time range.
### Write Events
### Create Event
Call `project_cli` with:
```json
{
"command": "calendar",
"subcommand": "write",
"subcommand": "create",
"args": {
"operations": []
"title": "Project sync",
"start_at": "2026-04-21T10:00:00+08:00",
"end_at": "2026-04-21T11:00:00+08:00",
"event_timezone": "Asia/Shanghai"
}
}
```
Each operation object requires:
- `action`: `create`, `update`, or `delete`
- create requires `start_at`, `event_timezone`
- update/delete require `event_id`
### Update Event
Call `project_cli` with:
```json
{
"command": "calendar",
"subcommand": "update",
"args": {
"event_id": "<uuid>",
"title": "Updated title"
}
}
```
### Delete Event
Call `project_cli` with:
```json
{
"command": "calendar",
"subcommand": "delete",
"args": {
"event_id": "<uuid>"
}
}
```
Read first if you need to confirm the write payload shape instead of relying on memory.
@@ -81,14 +108,14 @@ Call `project_cli` with:
1. To share an event with a friend:
- Call `view_skill_file` with `contacts/SKILL.md` if contacts instructions have not been read in this run
- Call `project_cli` `contacts lookup` to find friend phone numbers
- Call `project_cli` `contacts read` to find friend phone numbers
- Call `project_cli` `calendar share` with the selected phone
2. To update a specific event:
- Call `project_cli` `calendar read` to find the event_id
- Call `project_cli` `calendar write` with action `update`
- Call `project_cli` `calendar read` to find the event_id
- Call `project_cli` `calendar update` with target fields
## Failure Recovery
- If `calendar write` returns partial success, report which items failed and suggest retrying only those.
- If `calendar share` fails for a phone, suggest verifying the phone number with `contacts lookup`.
- If `calendar create/update/delete` returns failure, report why and suggest retrying with corrected parameters.
- If `calendar share` fails for a phone, suggest verifying the phone number with `contacts read`.
@@ -14,7 +14,7 @@ description: Contact lookup - find friend information including phone numbers fo
## When to Use
- User wants to share something with a friend but needs their contact info
- Agent needs phone numbers to pass to `calendar_share`
- Agent needs phone numbers to pass to `calendar share`
- User asks about their friend list
## Available Tool
@@ -23,14 +23,14 @@ Use the single tool `project_cli`.
Read this file first with `view_skill_file` when contacts is the relevant skill.
### Lookup Contacts
### Read Contacts
Call `project_cli` with:
```json
{
"command": "contacts",
"subcommand": "lookup",
"subcommand": "read",
"args": {}
}
```
@@ -43,7 +43,7 @@ Returns:
1. To share an event:
- Call `view_skill_file` with `calendar/SKILL.md` if calendar instructions have not been read in this run
- Call `project_cli` `contacts lookup` to get friend candidates
- Call `project_cli` `contacts read` to get friend candidates
- Match user's description to a friend
- Call `project_cli` `calendar share` with the friend's phone
@@ -24,43 +24,39 @@ Use the single tool `project_cli`.
Read this file first with `view_skill_file` when memory is the relevant skill.
### Write Memory
### Update Memory
Call `project_cli` with:
```json
{
"command": "memory",
"subcommand": "write",
"subcommand": "update",
"args": {
"operations": []
"operations": [
{
"action": "update",
"memory_type": "user",
"user_content": {}
}
]
}
}
```
Operation objects use `memory_type` (`user` or `work`) plus matching content.
### Forget Memory
Call `project_cli` with:
```json
{
"command": "memory",
"subcommand": "forget",
"args": {
"operations": []
}
}
```
Operation object fields:
- `action`: `update` or `delete`
- `memory_type`: `user` or `work`
- `update` requires matching content payload (`user_content` / `work_content`)
- `delete` requires `forget_paths`
## Composition Patterns
1. When user says "remember that I prefer morning meetings":
- Call `project_cli` `memory write` with `memory_type=user` and appropriate content
- Call `project_cli` `memory update` with `action=update`, `memory_type=user`, and appropriate content
2. When user says "forget my old address":
- Call `project_cli` `memory forget` with the specific dot-path
- Call `project_cli` `memory update` with `action=delete` and the specific dot-path
## Failure Recovery
@@ -1,8 +1,10 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from schemas.agent.ui_hints import UiHintIntent, UiHintsPayload, UiHintStatus
def _resolve_command_key(tool_output: ToolAgentOutput) -> tuple[str, str] | None:
@@ -28,71 +30,262 @@ def _result_data(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
return data if isinstance(data, dict) else None
def _status_from_tool(tool_output: ToolAgentOutput) -> UiHintStatus:
if tool_output.status == ToolStatus.SUCCESS:
return UiHintStatus.SUCCESS
if tool_output.status == ToolStatus.PARTIAL:
return UiHintStatus.WARNING
if tool_output.status == ToolStatus.FAILURE:
return UiHintStatus.ERROR
return UiHintStatus.INFO
def _status_from_result_item(value: object) -> UiHintStatus:
text = str(value or "").strip().lower()
if text == "success":
return UiHintStatus.SUCCESS
if text == "partial":
return UiHintStatus.WARNING
if text == "failure":
return UiHintStatus.ERROR
return UiHintStatus.INFO
def _build_status_ui_hints(
*,
tool_output: ToolAgentOutput,
intent: UiHintIntent,
title: str,
description: str,
items: list[dict[str, Any]],
list_title: str,
list_items: list[dict[str, Any]],
) -> dict[str, Any]:
payload = UiHintsPayload.model_validate(
{
"intent": intent,
"status": _status_from_tool(tool_output),
"title": title,
"description": description,
"items": items,
"sections": [{"title": list_title, "listItems": list_items}],
}
)
return payload.model_dump(mode="json", by_alias=True, exclude_none=True)
def _results_list(data: dict[str, Any]) -> list[dict[str, Any]]:
raw = data.get("results")
return [item for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
def _calendar_read_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
data = _result_data(tool_output)
if data is None:
return None
return {"view": "calendar_event_list", "total": data.get("total", 0)}
items_raw = data.get("items")
events = [item for item in items_raw if isinstance(item, dict)] if isinstance(items_raw, list) else []
list_items: list[dict[str, Any]] = []
for event in events:
event_id = str(event.get("id") or "").strip()
title = str(event.get("title") or "").strip()
start_at = str(event.get("startAt") or "").strip()
end_at = str(event.get("endAt") or "").strip()
subtitle = f"{start_at} ~ {end_at}" if start_at and end_at else (start_at or end_at or None)
list_items.append(
{
"id": event_id or None,
"title": title or "日程",
"subtitle": subtitle,
"status": UiHintStatus.INFO.value,
}
)
return _build_status_ui_hints(
tool_output=tool_output,
intent=UiHintIntent.LIST,
title="日程查询结果",
description="仅展示本次查询返回的日程列表。",
items=[
{"key": "total", "label": "日程数量", "value": int(data.get("total") or len(events))},
],
list_title="日程列表",
list_items=list_items,
)
def _calendar_write_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
def _calendar_mutation_ui_hints(
*,
tool_output: ToolAgentOutput,
action_label: str,
) -> dict[str, Any] | None:
data = _result_data(tool_output)
if data is None:
return None
return {
"view": "calendar_batch_result",
"status": data.get("status", tool_output.status.value),
"results": data.get("results", []),
}
success_count = int(data.get("success") or 0)
failed_count = int(data.get("failed") or 0)
list_items: list[dict[str, Any]] = []
for item in _results_list(data):
event_id = str(item.get("eventId") or "").strip()
status = _status_from_result_item(item.get("status")).value
code = str(item.get("code") or "").strip()
changed_fields = item.get("changedFields")
field_text = (
",".join([str(field).strip() for field in changed_fields if str(field).strip()])
if isinstance(changed_fields, list)
else ""
)
subtitle_parts: list[str] = []
if event_id:
subtitle_parts.append(f"event_id={event_id}")
if field_text:
subtitle_parts.append(f"fields={field_text}")
if code:
subtitle_parts.append(f"code={code}")
list_items.append(
{
"id": event_id or None,
"title": f"日程{action_label}",
"subtitle": " / ".join(subtitle_parts) if subtitle_parts else None,
"status": status,
}
)
return _build_status_ui_hints(
tool_output=tool_output,
intent=UiHintIntent.STATUS,
title=f"日程{action_label}结果",
description=f"仅展示本次日程{action_label}调用结果。",
items=[
{"key": "success", "label": "成功", "value": success_count},
{"key": "failed", "label": "失败", "value": failed_count},
{
"key": "status",
"label": "总体状态",
"value": str(data.get("status") or tool_output.status.value),
},
],
list_title="执行明细",
list_items=list_items,
)
def _calendar_share_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
def _calendar_create_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
return _calendar_mutation_ui_hints(tool_output=tool_output, action_label="创建")
def _calendar_update_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
return _calendar_mutation_ui_hints(tool_output=tool_output, action_label="更新")
def _calendar_delete_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
return _calendar_mutation_ui_hints(tool_output=tool_output, action_label="删除")
def _memory_update_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
data = _result_data(tool_output)
if data is None:
return None
return {
"view": "calendar_share_result",
"status": data.get("status", tool_output.status.value),
"results": data.get("results", []),
}
success_count = int(data.get("success") or 0)
failed_count = int(data.get("failed") or 0)
updated_types = data.get("updated_types")
updated = ", ".join(updated_types) if isinstance(updated_types, list) else ""
forgotten_total = int(data.get("forgotten") or 0)
list_items: list[dict[str, Any]] = []
for item in _results_list(data):
memory_type = str(item.get("memoryType") or "memory").strip()
memory_id = str(item.get("memoryId") or "").strip()
action = str(item.get("action") or "update").strip()
forgotten = int(item.get("forgotten") or 0)
status = _status_from_result_item(item.get("status")).value
subtitle_parts: list[str] = []
if memory_id:
subtitle_parts.append(f"memory_id={memory_id}")
if action:
subtitle_parts.append(f"action={action}")
if forgotten:
subtitle_parts.append(f"forgotten={forgotten}")
list_items.append(
{
"id": memory_id or None,
"title": f"{memory_type} memory",
"subtitle": " / ".join(subtitle_parts) if subtitle_parts else None,
"status": status,
}
)
return _build_status_ui_hints(
tool_output=tool_output,
intent=UiHintIntent.STATUS,
title="记忆更新结果",
description="仅展示本次 memory.update 的结构化状态。",
items=[
{"key": "success", "label": "成功", "value": success_count},
{"key": "failed", "label": "失败", "value": failed_count},
{
"key": "updated_types",
"label": "已更新类型",
"value": updated,
},
{"key": "forgotten", "label": "已清理条目", "value": forgotten_total},
],
list_title="执行明细",
list_items=list_items,
)
def _contacts_lookup_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
def _contacts_read_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
data = _result_data(tool_output)
if data is None:
return None
return {"view": "contact_list", "friends_count": data.get("friends_count", 0)}
contacts_raw = data.get("friends")
contacts = [item for item in contacts_raw if isinstance(item, dict)] if isinstance(contacts_raw, list) else []
list_items: list[dict[str, Any]] = []
for item in contacts:
user_id = str(item.get("userId") or "").strip()
username = str(item.get("username") or "").strip()
phone = str(item.get("phone") or "").strip()
list_items.append(
{
"id": user_id or None,
"title": username or phone or "联系人",
"subtitle": phone or None,
"status": UiHintStatus.INFO.value,
}
)
return _build_status_ui_hints(
tool_output=tool_output,
intent=UiHintIntent.LIST,
title="联系人读取结果",
description="仅展示当前可用联系人列表。",
items=[
{
"key": "friends_count",
"label": "联系人数量",
"value": int(data.get("friends_count") or len(contacts)),
}
],
list_title="联系人列表",
list_items=list_items,
)
def _memory_write_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
data = _result_data(tool_output)
if data is None:
return None
return {
"view": "memory_batch_result",
"status": data.get("status", tool_output.status.value),
"updated_types": data.get("updated_types", []),
}
def _memory_forget_ui_hints(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
data = _result_data(tool_output)
if data is None:
return None
return {
"view": "memory_batch_result",
"status": data.get("status", tool_output.status.value),
"forgotten": data.get("forgotten", 0),
}
_UI_HINTS_BUILDERS: dict[tuple[str, str], Any] = {
_UI_HINTS_BUILDERS: dict[tuple[str, str], Callable[[ToolAgentOutput], dict[str, Any] | None]] = {
("calendar", "create"): _calendar_create_ui_hints,
("calendar", "read"): _calendar_read_ui_hints,
("calendar", "write"): _calendar_write_ui_hints,
("calendar", "share"): _calendar_share_ui_hints,
("contacts", "lookup"): _contacts_lookup_ui_hints,
("memory", "write"): _memory_write_ui_hints,
("memory", "forget"): _memory_forget_ui_hints,
("calendar", "update"): _calendar_update_ui_hints,
("calendar", "delete"): _calendar_delete_ui_hints,
("contacts", "read"): _contacts_read_ui_hints,
("memory", "update"): _memory_update_ui_hints,
}
+20 -3
View File
@@ -8,7 +8,7 @@ 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.tool_middleware import register_tool_middlewares
from core.logging import get_logger
from schemas.agent.skill_config import SkillName
from schemas.agent.skill_config import ProjectCliCommand, SkillName
_logger = get_logger("core.agentscope.tools.toolkit")
@@ -26,9 +26,21 @@ def _validate_enabled_skill_names(skill_names: set[str]) -> set[str]:
return skill_names
def _all_command_names() -> set[str]:
return {command.value for command in ProjectCliCommand}
def _validate_allowed_commands(command_names: set[str]) -> set[str]:
unknown = command_names - _all_command_names()
if unknown:
raise ValueError(f"unknown commands in allowed_commands: {sorted(unknown)}")
return command_names
def build_toolkit(
*,
enabled_skill_names: set[str] | None = None,
allowed_commands: set[str] | None = None,
enable_hitl: bool | None = None,
) -> Any:
from agentscope.tool import Toolkit
@@ -40,9 +52,14 @@ def build_toolkit(
toolkit = Toolkit()
allowed_commands = enabled_skills
if allowed_commands is None:
resolved_allowed_commands = _all_command_names()
else:
resolved_allowed_commands = _validate_allowed_commands(allowed_commands)
project_cli_wrapper = make_project_cli_wrapper(allowed_commands=allowed_commands)
project_cli_wrapper = make_project_cli_wrapper(
allowed_commands=resolved_allowed_commands
)
toolkit.register_tool_function(
project_cli_wrapper,
func_name=PROJECT_CLI_TOOL_NAME,
@@ -24,3 +24,4 @@ agents:
enabled_skills:
- calendar
- contacts
- memory
@@ -11,6 +11,12 @@ class SkillName(str, Enum):
MEMORY = "memory"
class ProjectCliCommand(str, Enum):
CALENDAR = "calendar"
CONTACTS = "contacts"
MEMORY = "memory"
class EnabledSkillConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
+25 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from enum import Enum
from schemas.agent.skill_config import SkillName
from schemas.agent.skill_config import ProjectCliCommand, SkillName
from pydantic import BaseModel, Field, field_validator
@@ -29,6 +29,10 @@ class SystemAgentLLMConfig(BaseModel):
default_factory=ContextMessagesConfig
)
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
allowed_commands: list[ProjectCliCommand] = Field(
default_factory=lambda: [command for command in ProjectCliCommand],
max_length=32,
)
@field_validator("enabled_skills", mode="before")
@classmethod
@@ -49,3 +53,23 @@ class SystemAgentLLMConfig(BaseModel):
if skill not in normalized:
normalized.append(skill)
return normalized
@field_validator("allowed_commands", mode="before")
@classmethod
def _normalize_allowed_commands(cls, value: object) -> list[ProjectCliCommand]:
if value is None:
return [command for command in ProjectCliCommand]
if not isinstance(value, list):
raise ValueError("allowed_commands must be a list")
normalized: list[ProjectCliCommand] = []
for item in value:
if isinstance(item, ProjectCliCommand):
command = item
else:
raw_item = str(item or "").strip()
if not raw_item:
continue
command = ProjectCliCommand(raw_item)
if command not in normalized:
normalized.append(command)
return normalized
+9 -1
View File
@@ -6,7 +6,7 @@ from typing import Protocol
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from schemas.agent.skill_config import SkillName
from schemas.agent.skill_config import ProjectCliCommand, SkillName
from schemas.enums import AutomationJobStatus, ScheduleType
@@ -74,6 +74,10 @@ class RuntimeConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
allowed_commands: list[ProjectCliCommand] = Field(
default_factory=lambda: [command for command in ProjectCliCommand],
max_length=32,
)
context: MessageContextConfig = Field(default_factory=MessageContextConfig)
@@ -81,6 +85,10 @@ class AutomationJobConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_skills: list[SkillName] | None = Field(default=None, max_length=32)
allowed_commands: list[ProjectCliCommand] | None = Field(
default=None,
max_length=32,
)
context: MessageContextConfig | None = None
input_template: str | None = Field(default=None, min_length=1, max_length=4000)
schedule: ScheduleConfig | None = None
+7 -2
View File
@@ -159,10 +159,15 @@ class HistoryMessage(BaseModel):
id: str = Field(description="Message UUID")
seq: int = Field(description="Message sequence number")
role: Literal["user", "assistant"] = Field(
description="Message role: user | assistant"
role: Literal["user", "assistant", "tool"] = Field(
description="Message role: user | assistant | tool"
)
content: str = Field(description="Message text content")
suggested_actions: list[str] = Field(
default_factory=list,
alias="suggestedActions",
description="Suggested follow-up prompts for assistant messages",
)
attachments: list[HistoryMessageAttachment] = Field(
default_factory=list,
description="Temporary signed URLs for user-attached images",
-2
View File
@@ -530,8 +530,6 @@ class AgentService:
)
for msg_dict in raw_messages:
msg = AgentChatMessage.model_validate(msg_dict)
if msg.role == "tool":
continue
signed_urls: dict[str, str] = {}
attachments = extract_user_message_attachments(msg.metadata)
@@ -16,6 +16,7 @@ from schemas.domain.automation import (
MessageContextConfig,
RuntimeConfig,
)
from schemas.agent.skill_config import ProjectCliCommand
def _default_system_agents_path() -> Path:
@@ -97,7 +98,12 @@ def build_runtime_config_from_system_agents(
if worker_config and worker_config.enabled_skills:
enabled_skills = list(worker_config.enabled_skills)
allowed_commands = [command for command in ProjectCliCommand]
if worker_config and worker_config.allowed_commands:
allowed_commands = list(worker_config.allowed_commands)
return RuntimeConfig(
enabled_skills=enabled_skills,
allowed_commands=allowed_commands,
context=context_cfg,
)
+66 -1
View File
@@ -7,6 +7,8 @@
from collections.abc import Callable
from typing import Any
from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints
from schemas.agent.ui_hints import UiHintsPayload
from schemas.domain.chat_message import (
AgentChatMessage,
AgentChatMessageMetadata,
@@ -28,7 +30,8 @@ def convert_message_to_history(
转换规则:
- role=user: 读取 metadata.user_message_attachments,转换为 attachments[]
- role=assistant: 返回文本内容,不生成 ui_schema(UI 由 tool 结果单独承载)
- role=assistant: 返回 answer 文本 + suggested_actions
- role=tool: 从 metadata.tool_agent_output.ui_hints 编译并返回 ui_schema
"""
role = message.role
content = message.content
@@ -43,15 +46,77 @@ def convert_message_to_history(
"seq": message.seq,
"role": role,
"content": content,
"suggestedActions": _extract_suggested_actions(metadata),
"timestamp": message.timestamp.isoformat(),
}
ui_schema = _extract_tool_ui_schema(metadata)
if ui_schema is not None:
result["ui_schema"] = ui_schema
if attachments:
result["attachments"] = attachments
return result
def _extract_suggested_actions(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> list[str]:
if metadata is None:
return []
if isinstance(metadata, AgentChatMessageMetadata):
output = metadata.agent_output
if output is None:
return []
actions = output.suggested_actions
elif isinstance(metadata, dict):
output = metadata.get("agent_output")
if not isinstance(output, dict):
return []
actions = output.get("suggested_actions")
else:
return []
if not isinstance(actions, list):
return []
normalized: list[str] = []
for item in actions:
if not isinstance(item, str):
continue
text = item.strip()
if text:
normalized.append(text)
return normalized
def _extract_tool_ui_schema(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> dict[str, Any] | None:
if metadata is None:
return None
raw_ui_hints: Any = None
if isinstance(metadata, AgentChatMessageMetadata):
tool_output = metadata.tool_agent_output
if tool_output is not None:
raw_ui_hints = tool_output.ui_hints
elif isinstance(metadata, dict):
tool_output = metadata.get("tool_agent_output")
if isinstance(tool_output, dict):
raw_ui_hints = tool_output.get("ui_hints")
if raw_ui_hints is None:
return None
try:
ui_hints = UiHintsPayload.model_validate(raw_ui_hints)
return compile_ui_hints(ui_hints)
except Exception:
return None
def _convert_user_attachments(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
get_signed_url_fn: Callable[[dict[str, str]], str] | None,