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

This commit is contained in:
qzl
2026-04-22 17:09:37 +08:00
parent eeed737949
commit 4d55df45ab
111 changed files with 4858 additions and 3264 deletions
@@ -53,14 +53,6 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
payload = dict(event)
event_type = str(payload.get("type", "")).strip().upper()
if event_type == EventType.TEXT_MESSAGE_END.value:
ui_hints = payload.get("ui_hints")
if ui_hints is not None:
try:
ui_hints_payload = UiHintsPayload.model_validate(ui_hints)
ui_schema = compile_ui_hints(ui_hints_payload)
payload["ui_schema"] = ui_schema
except Exception:
pass
payload.pop("ui_hints", None)
for key in (
"inputTokens",
@@ -71,8 +63,14 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
):
payload.pop(key, None)
if event_type == EventType.TOOL_CALL_RESULT.value:
ui_hints = payload.get("ui_hints")
if ui_hints is not None:
try:
ui_hints_payload = UiHintsPayload.model_validate(ui_hints)
payload["ui_schema"] = compile_ui_hints(ui_hints_payload)
except Exception:
pass
payload.pop("ui_hints", None)
payload.pop("ui_schema", None)
return payload
@@ -180,7 +178,14 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]:
tool_result_payload["threadId"] = thread_id
if isinstance(run_id, str) and run_id:
tool_result_payload["runId"] = run_id
reserved = {"type", "threadId", "runId", "ui_hints", "ui_schema"}
ui_hints = data.get("ui_hints")
if ui_hints is not None:
try:
ui_hints_payload = UiHintsPayload.model_validate(ui_hints)
tool_result_payload["ui_schema"] = compile_ui_hints(ui_hints_payload)
except Exception:
pass
reserved = {"type", "threadId", "runId", "ui_hints"}
tool_result_payload.update({k: v for k, v in data.items() if k not in reserved})
return tool_result_payload
+1 -3
View File
@@ -133,11 +133,8 @@ class SqlAlchemyEventStore:
worker_output_fields = (
"status",
"answer",
"key_points",
"result_type",
"suggested_actions",
"error",
"ui_hints",
)
worker_output_payload: dict[str, object] = {}
for field in worker_output_fields:
@@ -331,6 +328,7 @@ class SqlAlchemyEventStore:
"status": self._event_value(event, "status"),
"result": self._event_value(event, "result"),
"error": self._event_value(event, "error"),
"ui_hints": self._event_value(event, "ui_hints"),
}
try:
@@ -4,12 +4,10 @@ from core.agentscope.prompts.memory_prompt import (
build_work_memory_prompt,
)
from core.agentscope.prompts.system_prompt import build_system_prompt
from core.agentscope.prompts.tool_prompt import build_tools_prompt
__all__ = [
"build_agent_prompt",
"build_user_memory_prompt",
"build_work_memory_prompt",
"build_system_prompt",
"build_tools_prompt",
]
@@ -2,9 +2,8 @@ from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
from schemas.agent.runtime_models import ResultType, RouterAgentOutput, TaskType
from schemas.agent.runtime_models import RouterAgentOutput
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
@@ -17,21 +16,17 @@ def _wrap_section(section: str, content: str) -> str:
return f"{start}\n{body}\n{end}" if body else f"{start}\n{end}"
def _enum_values(enum_cls: Any) -> str:
return ", ".join(item.value for item in enum_cls)
def _config_rules(llm_config: SystemAgentLLMConfig | None) -> list[str]:
if llm_config is None:
return []
context_mode = llm_config.context_messages.mode.value
context_count = llm_config.context_messages.count
enabled_tools = [tool.value for tool in llm_config.enabled_tools]
enabled_skills = [skill.value for skill in llm_config.enabled_skills]
return [
"[Runtime Config]",
f"- context_messages.mode={context_mode}",
f"- context_messages.count={context_count}",
f"- enabled_tools={','.join(enabled_tools) if enabled_tools else 'default'}",
f"- enabled_skills={','.join(enabled_skills) if enabled_skills else 'default'}",
]
@@ -64,16 +59,10 @@ def _router_rules(llm_config: SystemAgentLLMConfig | None) -> list[str]:
"- Return exactly one RouterAgentOutput JSON object.",
"[Responsibilities]",
"- Router only: extract intent and route strategy; never answer user directly.",
"- Preserve intent in normalized_task_input.user_text; keep wording concise and faithful.",
"- Fill multimodal_summary only when image/attachment changes execution decisions.",
"- Fill normalized_task_input.context_summary with a brief description of what the provided context messages contain; this is critical for worker to understand the conversational background.",
"- Return key_entities and constraints that are execution-relevant; low confidence -> omit rather than guess.",
"- Set execution_mode by complexity: onestep / tool_assisted / multistep.",
"- Set result_typing.primary to the most suitable response shape; use clarification_request only when required info is missing.",
f"- task_typing.primary must use one TaskType enum: {_enum_values(TaskType)}.",
f"- task_typing.secondary max 3 enums: {_enum_values(TaskType)}.",
f"- result_typing.primary must use one ResultType enum: {_enum_values(ResultType)}.",
f"- result_typing.secondary max 3 enums: {_enum_values(ResultType)}.",
"- Set objective to the user's goal in a concise, faithful sentence.",
"- Set context_summary to a brief description of what context messages contain.",
"- Set requires_tool_evidence=true when the task needs tool execution to ground the answer.",
"- Set requires_tool_evidence=false when the question can be answered directly from context.",
*_config_rules(llm_config),
]
@@ -85,11 +74,11 @@ def _worker_rules(llm_config: SystemAgentLLMConfig | None) -> list[str]:
"- Return exactly one worker output JSON object matching the runtime-injected schema.",
"[Responsibilities]",
"- Worker only: execute routed objective without changing router intent.",
"- Treat router output as objective/constraints contract, not as a fully-materialized tool-args payload.",
"- Treat router output as objective contract, not as a fully-materialized tool-args payload.",
"- Infer deterministic required tool arguments from contract fields, tool schema, and runtime context.",
"- Ask minimal clarification only when required arguments cannot be inferred safely.",
"- Ground every claim in available evidence and tool results; never fabricate execution state.",
"- Keep status/result_type/answer/key_points/suggested_actions/error internally consistent.",
"- Keep status/answer/suggested_actions/error internally consistent.",
"[Schema Guidance]",
"- The worker output schema is injected at runtime; follow it exactly.",
"- Do not add fields that are not present in the injected schema.",
@@ -107,9 +96,9 @@ def build_worker_contract_prompt(*, router_output: RouterAgentOutput) -> str:
[
"[Worker Contract]",
"- Keep routed objective unchanged.",
"- Use normalized_task_input as objective text.",
"- Use context_summary to understand conversational background from chat history.",
"- Use multimodal_summary/key_entities/constraints as execution evidence.",
"- Use objective as the execution target.",
"- Use context_summary to understand conversational background.",
"- When requires_tool_evidence=true, you MUST call at least one tool before answering.",
"- Infer deterministic missing required tool args from evidence + tool schema.",
"- Ask clarification only when safe inference is impossible.",
"[RouterAgentOutput]",
@@ -1,76 +0,0 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any, ClassVar
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class FrontendRoute(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
route_id: str
path: str
description: str
category: str
auth_required: bool
path_params: list[str] = Field(default_factory=list)
query_params: list[str] = Field(default_factory=list)
class FrontendRouteCatalog(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
version: str
routes: list[FrontendRoute]
def _default_catalog_path() -> Path:
return (
Path(__file__).resolve().parents[2]
/ "config"
/ "static"
/ "route"
/ "frontend_routes.yaml"
)
@lru_cache(maxsize=1)
def load_frontend_routes_catalog() -> FrontendRouteCatalog:
path = _default_catalog_path()
with path.open("r", encoding="utf-8") as file:
loaded: Any = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Invalid frontend routes catalog format: {path}")
try:
return FrontendRouteCatalog.model_validate(loaded)
except ValidationError as exc:
raise ValueError(f"Invalid frontend routes catalog data: {path}") from exc
def build_frontend_route_prompt() -> str:
catalog = load_frontend_routes_catalog()
lines = [
"[Frontend Route Catalog]",
f"version={catalog.version}",
"rules: use listed route_id only; output concrete path; no placeholders; no query in path; put query in params; params scalar only.",
"ROUTES:",
]
for route in catalog.routes:
path_params = ", ".join(route.path_params) if route.path_params else "none"
query_params = ", ".join(route.query_params) if route.query_params else "none"
lines.append(
"- "
f"route_id={route.route_id}; "
f"path={route.path}; "
f"path_params={path_params}; "
f"query_params={query_params}"
)
return "\n".join(lines)
@@ -2,10 +2,9 @@ from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Sequence
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from ag_ui.core.types import Tool
from core.agentscope.prompts.agent_prompt import (
build_agent_prompt,
)
@@ -13,8 +12,6 @@ from core.agentscope.prompts.memory_prompt import (
build_user_memory_prompt,
build_work_memory_prompt,
)
from core.agentscope.prompts.route_prompt import build_frontend_route_prompt
from core.agentscope.prompts.tool_prompt import build_tools_prompt
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
from schemas.agent.forwarded_props import ClientTimeContext
from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent
@@ -25,7 +22,6 @@ def _wrap_section(section: str, content: str) -> str:
marker_map = {
"env": ("<!-- ENV_START -->", "<!-- ENV_END -->"),
"identity": ("<!-- IDENTITY_START -->", "<!-- IDENTITY_END -->"),
"route": ("<!-- ROUTE_START -->", "<!-- ROUTE_END -->"),
"schema": ("<!-- SCHEMA_START -->", "<!-- SCHEMA_END -->"),
"safety": ("<!-- SAFETY_START -->", "<!-- SAFETY_END -->"),
"output": ("<!-- OUTPUT_START -->", "<!-- OUTPUT_END -->"),
@@ -200,10 +196,6 @@ def _build_output_rules() -> str:
)
def _build_route_section() -> str:
return _wrap_section("route", build_frontend_route_prompt())
def build_system_prompt(
*,
agent_type: AgentType,
@@ -212,12 +204,9 @@ def build_system_prompt(
now_utc: datetime,
runtime_client_time: ClientTimeContext | None = None,
extra_context: str | None = None,
tools: Sequence[Tool | dict[str, Any]] | None = None,
user_memory: UserMemoryContent | None = None,
work_memory: WorkProfileContent | None = None,
) -> str:
include_route_section = agent_type == AgentType.WORKER
if agent_type == AgentType.ROUTER:
memory_prompt = build_user_memory_prompt(user_memory=user_memory)
else:
@@ -231,13 +220,11 @@ def build_system_prompt(
runtime_client_time=runtime_client_time,
extra_context=extra_context,
),
_build_route_section() if include_route_section else None,
_build_safety_section(),
build_agent_prompt(
agent_type=agent_type,
llm_config=llm_config,
),
build_tools_prompt(tools=tools) if tools else None,
memory_prompt,
_build_output_rules(),
]
@@ -1,42 +0,0 @@
from __future__ import annotations
import json
from typing import Any, Iterable
from ag_ui.core.types import Tool
def _wrap_section(section: str, content: str) -> str:
marker_map = {
"tools": ("<!-- TOOLS_START -->", "<!-- TOOLS_END -->"),
}
start, end = marker_map[section]
body = content.strip()
return f"{start}\n{body}\n{end}" if body else f"{start}\n{end}"
def build_tools_prompt(
*,
tools: Iterable[Tool | dict[str, Any]],
) -> str:
lines: list[str] = []
lines.append("[Available Tools]")
for item in tools:
if isinstance(item, dict):
name = str(item.get("name") or "")
description = str(item.get("description") or "")
parameters = item.get("parameters")
parameters = parameters if isinstance(parameters, dict) else {}
else:
name = item.name
description = item.description or ""
parameters = item.parameters or {}
lines.append(f"- {name}: {description}")
lines.append(
" - args_schema: "
+ json.dumps(parameters, ensure_ascii=True, separators=(",", ":"))
)
lines.append("Note: tool arguments must strictly match args_schema.")
return _wrap_section("tools", "\n".join(lines))
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any
from typing import Any, Literal
from agentscope.agent import ReActAgent
from agentscope.message import Msg
@@ -15,11 +15,14 @@ class JsonReActAgent(ReActAgent):
*,
emitter: Any = None,
finalize_retries: int = 2,
force_tool_on_first_reasoning: bool = False,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._pipeline_emitter = emitter
self._finalize_retries = max(finalize_retries, 0)
self._force_tool_on_first_reasoning = force_tool_on_first_reasoning
self._first_reasoning_done = False
self.set_console_output_enabled(False)
async def print(self, msg: Msg, last: bool = True, speech: Any = None) -> None:
@@ -27,6 +30,15 @@ class JsonReActAgent(ReActAgent):
if self._pipeline_emitter is not None:
await self._pipeline_emitter.handle_print(msg=msg, last=last)
async def _reasoning(
self,
tool_choice: Literal["auto", "none", "required"] | None = None,
) -> Msg:
if self._force_tool_on_first_reasoning and not self._first_reasoning_done:
self._first_reasoning_done = True
tool_choice = "required"
return await super()._reasoning(tool_choice)
async def reply_json(
self,
msg: Msg | list[Msg] | None,
+12 -23
View File
@@ -5,7 +5,6 @@ import contextlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from uuid import UUID
from ag_ui.core.types import RunAgentInput
from agentscope.formatter import OpenAIChatFormatter
@@ -18,7 +17,6 @@ from core.agentscope.schemas.agui_input import extract_latest_user_payload
from core.agentscope.runtime.json_react_agent import JsonReActAgent
from core.agentscope.runtime.model_tracking import TrackingChatModel
from core.agentscope.runtime.stage_emitter import PipelineStageEmitter
from core.agentscope.tools.tool_config import AgentTool, resolve_tool_function_names
from core.agentscope.tools.toolkit import build_toolkit
from core.agentscope.utils import (
finalize_json_response,
@@ -38,8 +36,8 @@ from schemas.agent.forwarded_props import (
from schemas.agent.runtime_models import (
RouterAgentOutput,
WorkerAgentOutputLite,
resolve_worker_output_model,
)
from schemas.agent.skill_config import SkillName
from schemas.agent.system_agent import (
AgentType,
SystemAgentLLMConfig,
@@ -83,7 +81,6 @@ class AgentScopeRunner:
work_memory: WorkProfileContent | None = None,
cancel_checker: Callable[[], Awaitable[bool]] | None = None,
) -> dict[str, Any]:
owner_id = UUID(user_context.id)
runtime_client_time = self._resolve_runtime_client_time(run_input=run_input)
runtime_mode = self._resolve_runtime_mode(run_input=run_input)
stop_cancel_watch = asyncio.Event()
@@ -110,9 +107,7 @@ class AgentScopeRunner:
agent_type=AgentType.WORKER,
)
worker_toolkit = self._build_toolkit(
session=session,
owner_id=owner_id,
enabled_tools=runtime_config.enabled_tools,
enabled_skills=runtime_config.enabled_skills
)
router_output = await self._execute_router_step(
@@ -178,19 +173,11 @@ class AgentScopeRunner:
def _build_toolkit(
self,
*,
session: AsyncSession,
owner_id: UUID,
enabled_tools: list[AgentTool],
enabled_skills: list[SkillName],
) -> Any:
tool_names = (
sorted(resolve_tool_function_names(set(enabled_tools)))
if enabled_tools
else []
)
enabled_skill_names = {str(skill.value) for skill in enabled_skills}
return build_toolkit(
session=session,
owner_id=owner_id,
enabled_tool_names=set(tool_names) if tool_names else None,
enabled_skill_names=enabled_skill_names if enabled_skill_names else None
)
async def _load_stage_config(
@@ -279,7 +266,6 @@ class AgentScopeRunner:
runtime_mode: RuntimeMode,
work_memory: WorkProfileContent | None,
) -> WorkerAgentOutputLite:
worker_output_model = resolve_worker_output_model(router_output.execution_mode)
await self._emit_step_event(
pipeline=pipeline,
run_input=run_input,
@@ -295,13 +281,14 @@ class AgentScopeRunner:
toolkit=toolkit,
run_input=run_input,
stage_config=stage_config,
worker_output_model=worker_output_model,
worker_output_model=WorkerAgentOutputLite,
pipeline=pipeline,
runtime_client_time=runtime_client_time,
runtime_mode=runtime_mode,
work_memory=work_memory,
requires_tool_evidence=router_output.requires_tool_evidence,
)
worker_output = worker_output_model.model_validate(worker_result.payload)
worker_output = WorkerAgentOutputLite.model_validate(worker_result.payload)
await self._emit_step_event(
pipeline=pipeline,
run_input=run_input,
@@ -338,7 +325,6 @@ class AgentScopeRunner:
user_context=user_context,
now_utc=datetime.now(timezone.utc),
runtime_client_time=runtime_client_time,
tools=None,
user_memory=user_memory,
),
"system",
@@ -400,6 +386,7 @@ class AgentScopeRunner:
runtime_client_time: ClientTimeContext | None,
runtime_mode: RuntimeMode,
work_memory: WorkProfileContent | None,
requires_tool_evidence: bool = False,
) -> StageExecutionResult:
tracking_model = self._build_model(stage_config=stage_config)
emitter = PipelineStageEmitter(
@@ -420,12 +407,12 @@ class AgentScopeRunner:
now_utc=datetime.now(timezone.utc),
runtime_client_time=runtime_client_time,
extra_context=stage_config.extra_context,
tools=None,
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
@@ -494,6 +481,7 @@ class AgentScopeRunner:
toolkit: Any,
model: TrackingChatModel,
emitter: PipelineStageEmitter | None = None,
force_tool_on_first_reasoning: bool = False,
) -> JsonReActAgent:
return JsonReActAgent(
name=agent_name,
@@ -503,6 +491,7 @@ class AgentScopeRunner:
toolkit=toolkit,
memory=InMemoryMemory(),
emitter=emitter,
force_tool_on_first_reasoning=force_tool_on_first_reasoning,
)
async def _emit_step_event(
@@ -6,6 +6,9 @@ from uuid import uuid4
from agentscope.message import Msg
from core.agentscope.utils import parse_tool_agent_output
from core.logging import get_logger
_logger = get_logger("core.agentscope.runtime.stage_emitter")
class PipelineLike(Protocol):
@@ -58,15 +61,10 @@ class PipelineStageEmitter:
"stage": self._stage,
"status": worker_output.get("status"),
"answer": worker_output.get("answer", ""),
"key_points": worker_output.get("key_points", []),
"result_type": worker_output.get("result_type"),
"suggested_actions": worker_output.get("suggested_actions", []),
"error": worker_output.get("error"),
**response_metadata,
}
ui_hints = worker_output.get("ui_hints")
if ui_hints is not None:
payload["ui_hints"] = ui_hints
await self._emit("TEXT_MESSAGE_END", payload)
async def _emit_text_events_from_msg(self, msg: Msg) -> None:
@@ -105,6 +103,11 @@ class PipelineStageEmitter:
continue
tool_output = parse_tool_agent_output(block.get("output"))
if tool_output is None:
_logger.warning(
"tool_result_block_skipped",
tool_call_id=tool_call_id,
output_type=type(block.get("output")).__name__,
)
continue
payload = {
"messageId": str(msg.id),
@@ -116,6 +119,8 @@ class PipelineStageEmitter:
"status": tool_output.status.value,
"result": tool_output.result,
}
if tool_output.ui_hints is not None:
payload["ui_hints"] = tool_output.ui_hints
if tool_output.error:
payload["error"] = tool_output.error.model_dump(mode="json")
@@ -0,0 +1,3 @@
from core.agentscope.tools.cli.adapter import invoke_cli_tool as invoke_cli_tool
from core.agentscope.tools.cli.handlers import build_router as build_router
from core.agentscope.tools.cli.router import CommandRouter as CommandRouter
@@ -0,0 +1,186 @@
from __future__ import annotations
import json
from typing import Any
from agentscope.tool import ToolResponse
from agentscope.message import TextBlock
from core.agentscope.tools.cli.handlers import build_router
from core.agentscope.tools.cli.models import CliCommand
from core.agentscope.tools.cli.router import CommandRouter
from core.agentscope.tools.tool_call_context import (
get_current_tool_call_id,
store_tool_agent_output,
)
from core.agentscope.utils.parsing import project_tool_result_text
from core.auth.credential_issuer import create_credential_issuer
from core.auth.jwt_verifier import TokenValidationError
from core.auth.tool_credential_context import get_tool_credential
from core.logging import get_logger
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
logger = get_logger("core.agentscope.tools.cli_adapter")
_CACHED_ROUTER: CommandRouter | None = None
def _get_router() -> CommandRouter:
global _CACHED_ROUTER
if _CACHED_ROUTER is None:
_CACHED_ROUTER = build_router()
return _CACHED_ROUTER
def _resolve_owner_id() -> str:
credential = get_tool_credential()
if not credential:
raise TokenValidationError("tool credential not found in runtime context")
issuer = create_credential_issuer()
claims = issuer.verify(credential)
owner_id = claims.get("sub")
if not isinstance(owner_id, str) or not owner_id.strip():
raise TokenValidationError("tool credential has no valid subject")
return owner_id
async def invoke_cli_tool(
*,
tool_name: str,
tool_call_args: dict[str, Any],
allowed_commands: set[str] | None = None,
) -> ToolResponse:
command = str(tool_call_args.get("command", "")).strip()
subcommand = str(tool_call_args.get("subcommand", "")).strip()
args = tool_call_args.get("args")
if isinstance(args, str):
try:
parsed_args = json.loads(args)
except (json.JSONDecodeError, ValueError):
parsed_args = None
if isinstance(parsed_args, dict):
args = parsed_args
if not isinstance(args, dict):
args = {}
if tool_name != "project_cli":
return _build_error(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="UNKNOWN_TOOL",
message=f"unsupported tool: {tool_name}",
)
if not command or not subcommand:
return _build_error(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="command and subcommand are required",
)
router = _get_router()
if allowed_commands is not None and command not in allowed_commands:
return _build_error(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="COMMAND_NOT_ALLOWED",
message=f"command not enabled: {command}",
)
if (command, subcommand) not in router.command_pairs:
return _build_error(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="UNKNOWN_COMMAND",
message=f"unknown command: {command} {subcommand}",
)
try:
owner_id = _resolve_owner_id()
except TokenValidationError as exc:
logger.error("Tool credential verification failed", tool_name=tool_name, error=str(exc))
return _build_error(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="CREDENTIAL_INVALID",
message=str(exc),
)
request = CliCommand(
command=command,
subcommand=subcommand,
args=args,
owner_id=owner_id,
)
try:
cli_result = await router.dispatch(request)
except Exception as exc:
logger.error("CLI dispatch failed", tool_name=tool_name, error=str(exc))
return _build_error(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="CLI_DISPATCH_ERROR",
message=str(exc),
)
status = ToolStatus.SUCCESS if cli_result.ok else ToolStatus.FAILURE
error_info = cli_result.error
result = {
"command": cli_result.command,
"subcommand": cli_result.subcommand,
"data": cli_result.data,
}
tool_call_id = get_current_tool_call_id(tool_name=tool_name)
output = ToolAgentOutput(
tool_name=tool_name,
tool_call_id=tool_call_id,
tool_call_args=tool_call_args,
status=status,
result=result,
error=error_info,
)
from core.agentscope.tools.tool_postprocessor import postprocess_tool_output
processed = postprocess_tool_output(output)
payload = processed.model_dump(mode="json", exclude_none=True)
store_tool_agent_output(tool_call_id=processed.tool_call_id, payload=payload)
return ToolResponse(
content=[
TextBlock(
type="text",
text=project_tool_result_text(processed.result),
)
]
)
def _build_error(
*,
tool_name: str,
tool_call_args: dict[str, Any] | None,
code: str,
message: str,
) -> ToolResponse:
tool_call_id = get_current_tool_call_id(tool_name=tool_name)
output = ToolAgentOutput(
tool_name=tool_name,
tool_call_id=tool_call_id,
tool_call_args=tool_call_args,
status=ToolStatus.FAILURE,
result={"status": "failure", "code": code, "message": message},
error=ErrorInfo(code=code, message=message, retryable=False),
)
payload = output.model_dump(mode="json", exclude_none=True)
store_tool_agent_output(tool_call_id=tool_call_id, payload=payload)
return ToolResponse(
content=[
TextBlock(
type="text",
text=project_tool_result_text(output.result),
)
]
)
@@ -0,0 +1,246 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
from core.agentscope.tools.utils.calendar_domain import (
build_schedule_metadata,
create_schedule_service,
map_calendar_exception,
merge_schedule_metadata_for_update,
parse_iso_datetime,
schedule_event_to_dict,
)
from schemas.enums import ScheduleItemStatus
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemListRequest,
ScheduleItemShareRequest,
ScheduleItemUpdateRequest,
)
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)
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")
if parsed_start >= parsed_end:
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})
async def handle_calendar_write(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({
"status": "failure",
"eventId": op.get("event_id"),
"code": code,
"message": message,
})
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_share(request: CliCommand) -> CliCommandResult:
from core.db.session import AsyncSessionLocal
event_id = str(request.args.get("event_id", ""))
invitees = request.args.get("invitees")
if not isinstance(invitees, list):
invitees = []
async with AsyncSessionLocal() as session:
service = create_schedule_service(session, UUID(request.owner_id))
target_uuid = UUID(event_id)
invited: list[str] = []
result_items: list[dict[str, str]] = []
for inv in invitees:
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"})
continue
permission = {
"permission_view": inv.get("permission_view", True),
"permission_edit": inv.get("permission_edit", False),
"permission_invite": inv.get("permission_invite", False),
}
try:
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})
failure_count = len([r for r in result_items if r["status"] == "failure"])
success_count = len(invited)
status = _batch_status(success_count, failure_count)
return CliCommandResult(
ok=status != "failure",
command=request.command,
subcommand=request.subcommand,
data={
"status": status,
"success": success_count,
"failed": failure_count,
"results": result_items,
},
)
async def _create_event(service: Any, op: dict[str, Any]) -> dict[str, Any]:
start_at = op.get("start_at")
if not start_at:
raise ValueError("create requires start_at")
event_timezone = op.get("event_timezone")
if not event_timezone:
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
created = await service.create_agent_generated(
ScheduleItemCreateRequest(
title=(op.get("title") or "new event").strip(),
description=op.get("description", "").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"),
),
)
)
return {"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:
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")):
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"),
)
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}
def _normalize_phone(raw: str) -> str:
phone = raw
for sep in (" ", "-", "(", ")"):
phone = phone.replace(sep, "")
if phone.startswith("0086"):
phone = f"+86{phone[4:]}"
elif phone.startswith("86") and phone[2:].isdigit():
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":
return ""
return phone
def _batch_status(success: int, failed: int) -> str:
if failed == 0:
return "success"
if success == 0:
return "failure"
return "partial"
def _fail(*, request: CliCommand, code: str, message: str) -> CliCommandResult:
from schemas.agent.runtime_models import ErrorInfo
return CliCommandResult(
ok=False,
command=request.command,
subcommand=request.subcommand,
error=ErrorInfo(code=code, message=message, retryable=False),
)
@@ -0,0 +1,92 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
from sqlalchemy import or_, select
from models.friendships import Friendship
from models.profile import Profile
from schemas.enums import FriendshipStatus
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:
from core.db.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
contacts = await _list_friend_contacts(session=session, owner_id=UUID(request.owner_id))
return CliCommandResult(
ok=True,
command=request.command,
subcommand=request.subcommand,
data={
"friends_count": len(contacts),
"friends": contacts,
},
)
async def _list_friend_contacts(
*,
session: Any,
owner_id: UUID,
) -> list[dict[str, str]]:
friendships_stmt = (
select(Friendship)
.where(
or_(
Friendship.user_low_id == owner_id,
Friendship.user_high_id == owner_id,
)
)
.where(Friendship.status == FriendshipStatus.ACCEPTED)
.where(Friendship.deleted_at.is_(None))
)
friendships = (await session.execute(friendships_stmt)).scalars().all()
friend_ids: list[UUID] = []
for friendship in friendships:
friend_id = (
friendship.user_high_id
if friendship.user_low_id == owner_id
else friendship.user_low_id
)
friend_ids.append(friend_id)
if not friend_ids:
return []
profiles_stmt = (
select(Profile)
.where(Profile.id.in_(friend_ids))
.where(Profile.deleted_at.is_(None))
)
profiles = (await session.execute(profiles_stmt)).scalars().all()
profiles_by_id = {profile.id: profile for profile in profiles}
auth_gateway = SupabaseAuthGateway()
resolved_contacts = await resolve_contacts_by_user_ids(
user_ids=friend_ids,
profiles_by_id=profiles_by_id,
auth_gateway=auth_gateway,
)
contacts: list[dict[str, str]] = []
for friend_id in friend_ids:
contact = resolved_contacts.get(friend_id)
if contact is None:
continue
phone = contact.phone
if not phone:
continue
contacts.append(
{
"userId": str(friend_id),
"username": str(contact.username or ""),
"phone": phone,
}
)
contacts.sort(key=lambda item: (item["username"], item["phone"]))
return contacts
@@ -0,0 +1,206 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from uuid import UUID
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
from core.agentscope.tools.utils.memory_domain import (
create_memories_service,
map_memory_exception,
)
from schemas.enums import MemoryType
from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent
async def handle_memory_write(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
updated_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"))
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)
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)
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})
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 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
return CliCommandResult(
ok=status != "failure",
command=request.command,
subcommand=request.subcommand,
data={
"status": status,
"success": success_count,
"failed": failed_count,
"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
return ErrorInfo(
code=str(error.get("code", "MEMORY_BATCH_FAILED")),
message=str(error.get("message", "memory operation failed")),
retryable=bool(error.get("retryable", False)),
)
def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
merged = deepcopy(base)
for key, value in patch.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge_dict(cast(dict[str, Any], merged[key]), value)
else:
merged[key] = value
return merged
def _remove_content_paths(
base_payload: dict[str, Any],
paths: list[str],
) -> tuple[dict[str, Any], list[str]]:
result = deepcopy(base_payload)
removed: list[str] = []
for raw_path in paths:
path = raw_path.strip()
if not path:
continue
keys = [part for part in path.split(".") if part]
if not keys:
continue
if _delete_nested_path(result, keys):
removed.append(path)
return result, removed
def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool:
current: dict[str, Any] = payload
for key in keys[:-1]:
next_value = current.get(key)
if not isinstance(next_value, dict):
return False
current = next_value
leaf = keys[-1]
if leaf in current:
del current[leaf]
return True
return False
def _batch_status(success: int, failed: int) -> str:
if failed == 0:
return "success"
if success == 0:
return "failure"
return "partial"
@@ -0,0 +1,24 @@
from __future__ import annotations
from core.agentscope.tools.cli.handler_calendar import (
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,
)
from core.agentscope.tools.cli.router import CommandRouter
def build_router() -> CommandRouter:
router = CommandRouter()
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="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)
return router
@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.runtime_models import ErrorInfo
class CliCommand(BaseModel):
model_config = ConfigDict(extra="forbid")
command: str
subcommand: str
args: dict[str, Any] = Field(default_factory=dict)
owner_id: str
class CliCommandResult(BaseModel):
model_config = ConfigDict(extra="forbid")
ok: bool
command: str
subcommand: str
data: Any = None
error: ErrorInfo | None = None
@@ -0,0 +1,117 @@
from __future__ import annotations
import json
import sys
from typing import Any, Awaitable, Callable
from core.agentscope.tools.cli.models import CliCommand, CliCommandResult
from core.logging import get_logger
from schemas.agent.runtime_models import ErrorInfo
logger = get_logger("core.agentscope.tools.cli.router")
CliHandler = Callable[[CliCommand], Awaitable[CliCommandResult]]
class CommandRouter:
def __init__(self) -> None:
self._handlers: dict[tuple[str, str], CliHandler] = {}
def register(self, *, command: str, subcommand: str, handler: CliHandler) -> None:
key = (command, subcommand)
if key in self._handlers:
raise ValueError(f"command already registered: {command} {subcommand}")
self._handlers[key] = handler
@property
def commands(self) -> set[str]:
return {command for command, _ in self._handlers.keys()}
@property
def command_pairs(self) -> set[tuple[str, str]]:
return set(self._handlers.keys())
async def dispatch(self, request: CliCommand) -> CliCommandResult:
handler = self._handlers.get((request.command, request.subcommand))
if handler is None:
return CliCommandResult(
ok=False,
command=request.command,
subcommand=request.subcommand,
error=ErrorInfo(
code="UNKNOWN_COMMAND",
message=f"unknown command: {request.command} {request.subcommand}",
retryable=False,
),
)
try:
return await handler(request)
except Exception as exc:
logger.error(
"CLI handler failed",
command=request.command,
subcommand=request.subcommand,
error=str(exc),
)
return CliCommandResult(
ok=False,
command=request.command,
subcommand=request.subcommand,
error=ErrorInfo(
code="HANDLER_ERROR",
message=str(exc),
retryable=False,
),
)
async def cli_main(argv: list[str] | None = None) -> None:
from core.agentscope.tools.cli.handlers import build_router
router = build_router()
if argv is None:
argv = sys.argv[1:]
if len(argv) < 2:
_write_output(
CliCommandResult(
ok=False,
command=argv[0] if argv else "",
subcommand=argv[1] if len(argv) > 1 else "",
error=ErrorInfo(
code="MISSING_COMMAND",
message="command and subcommand are required",
retryable=False,
),
)
)
sys.exit(1)
args: dict[str, Any] = {}
if len(argv) > 2:
try:
args = json.loads(argv[2])
except (json.JSONDecodeError, TypeError):
_write_output(
CliCommandResult(
ok=False,
command=argv[0],
subcommand=argv[1],
error=ErrorInfo(
code="INVALID_ARGS",
message="args must be valid JSON",
retryable=False,
),
)
)
sys.exit(1)
request = CliCommand(command=argv[0], subcommand=argv[1], args=args, owner_id=str(args.get("owner_id", "")))
result = await router.dispatch(request)
_write_output(result)
if not result.ok:
sys.exit(1)
def _write_output(payload: CliCommandResult) -> None:
sys.stdout.write(json.dumps(payload.model_dump(mode="json", exclude_none=True), ensure_ascii=False, separators=(",", ":")))
sys.stdout.write("\n")
sys.stdout.flush()
@@ -1,21 +0,0 @@
from core.agentscope.tools.custom.calendar import (
calendar_share,
calendar_read,
calendar_write,
)
from core.agentscope.tools.custom.user_lookup import (
user_lookup,
)
from core.agentscope.tools.custom.memory import (
memory_forget,
memory_write,
)
__all__ = [
"calendar_read",
"calendar_write",
"calendar_share",
"user_lookup",
"memory_write",
"memory_forget",
]
@@ -1,691 +0,0 @@
import json
from typing import Annotated, Any, Literal, cast
from uuid import UUID
from agentscope.tool import ToolResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.utils.calendar_domain import (
build_schedule_metadata,
create_schedule_service,
map_calendar_exception,
merge_schedule_metadata_for_update,
parse_iso_datetime,
schedule_event_to_dict,
)
from core.agentscope.tools.utils.calendar_ui import (
calendar_error_output,
dump_tool_output,
)
from core.agentscope.tools.tool_call_context import get_current_tool_call_id
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemListRequest,
ScheduleItemShareRequest,
ScheduleItemStatus,
ScheduleItemUpdateRequest,
)
class CalendarShareInvitee(BaseModel):
model_config = ConfigDict(extra="forbid")
phone: str = Field(
alias="phone",
description=(
"Target invitee phone. Accepts +8613xxxxxxxxx / 8613xxxxxxxxx "
"/ 13xxxxxxxxx and normalizes to E.164 (+86...)."
),
)
permission_view: bool = Field(
default=True,
alias="permissionView",
description="Whether the invitee can view the event.",
)
permission_edit: bool = Field(
default=False,
alias="permissionEdit",
description="Whether the invitee can edit the event.",
)
permission_invite: bool = Field(
default=False,
alias="permissionInvite",
description="Whether the invitee can invite other users.",
)
class CalendarWriteOperation(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["create", "update", "delete"] = Field(
description="Action type for this operation item."
)
event_id: str | None = Field(
default=None,
description="Event id required for update/delete.",
)
title: str | None = Field(default=None, description="Event title.")
description: str | None = Field(default=None, description="Event description.")
start_at: str | None = Field(
default=None,
description="Start time in ISO 8601 with timezone offset.",
)
end_at: str | None = Field(
default=None,
description="End time in ISO 8601 with timezone offset.",
)
event_timezone: str | None = Field(
default=None,
description="IANA timezone for the event.",
)
location: str | None = Field(default=None, description="Event location.")
color: str | None = Field(default=None, description="Event color.")
reminder_minutes: int | None = Field(
default=5,
ge=0,
le=10080,
description="Reminder minutes before event start. Defaults to 5 minutes if not specified.",
)
status: Literal["active", "archived"] | None = Field(
default=None,
description="Optional status for update action.",
)
class CalendarWriteBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[CalendarWriteOperation] = Field(min_length=1, max_length=20)
class CalendarShareArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
event_id: str
invitees: list[CalendarShareInvitee] = Field(min_length=1)
def _validate_runtime_context(
*,
tool_name: str,
tool_call_args: dict[str, Any],
session: Any,
owner_id: Any,
) -> ToolResponse | None:
if session is None or owner_id is None:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="MISSING_RUNTIME_ARGS",
message="日历工具缺少运行时参数",
retryable=False,
)
return None
async def calendar_read(
start_at: Annotated[
str,
Field(
description="Start of date range in ISO8601 with timezone, e.g. 2026-03-30T00:00:00+08:00."
),
],
end_at: Annotated[
str,
Field(
description="End of date range in ISO8601 with timezone, e.g. 2026-03-30T23:59:59+08:00."
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Read calendar events within a date range.
Returns subscribed calendar events (owned or shared) with permission info.
Status: active=actionable, archived=past/expired.
Permission flags: is_owner, can_view, can_edit, can_invite, can_delete.
Args:
start_at: Start of date range (required).
end_at: End of date range (required).
Returns:
ToolResponse with JSON result:
{
"total": int,
"items": [{
"id": "uuid",
"owner_id": "uuid",
"title": "string",
"description": "string|null",
"start_at": "ISO8601 datetime",
"end_at": "ISO8601 datetime|null",
"timezone": "IANA timezone",
"status": "active|archived",
"source_type": "manual|imported|agent_generated",
"permission": {"can_view", "can_edit", "can_invite", "can_delete", "is_owner"},
"is_owner": boolean,
"metadata": {color, location, reminder_minutes}|null,
"subscribers": [{user_id, username, phone, permission, status}],
"created_at": "ISO8601 datetime",
"updated_at": "ISO8601 datetime"
}]
}
"""
tool_name = "calendar_read"
tool_call_args: dict[str, Any] = {"start_at": start_at, "end_at": end_at}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
parsed_start = parse_iso_datetime(start_at)
parsed_end = parse_iso_datetime(end_at)
if parsed_start is None or parsed_end is None:
raise ValueError("start_at 和 end_at 都是必填项")
if parsed_start >= parsed_end:
raise ValueError("start_at 必须早于 end_at")
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
request = ScheduleItemListRequest(start_at=parsed_start, end_at=parsed_end)
items = await service.list_by_date_range(request)
event_items = [schedule_event_to_dict(item) for item in items]
result = json.dumps(
{"total": len(event_items), "items": event_items},
ensure_ascii=False,
)
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result=result,
)
)
except Exception as exc:
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
)
async def calendar_write(
operations: Annotated[
list[CalendarWriteOperation],
Field(
description=(
"Batch operation objects. Each item includes action and its fields. "
"Use create/update/delete in a single call."
),
min_length=1,
max_length=20,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Batch create/update/delete calendar events using operation objects.
Args:
operations: Batch operation objects.
- create requires start_at and event_timezone.
- update/delete requires event_id.
- datetime fields must include timezone offset.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_name = "calendar_write"
try:
parsed_batch = CalendarWriteBatchArgs.model_validate({"operations": operations})
except Exception as exc: # noqa: BLE001
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args={"operations": operations},
code=code,
message=message,
retryable=retryable,
)
tool_call_args = {
"operations": [
operation.model_dump(mode="json", exclude_none=True)
for operation in parsed_batch.operations
]
}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
success_count = 0
failed_count = 0
success_event_ids: list[str] = []
result_items: list[dict[str, Any]] = []
for operation in parsed_batch.operations:
event_id = operation.event_id
title = operation.title
description = operation.description
start_at = operation.start_at
end_at = operation.end_at
event_timezone = operation.event_timezone
location = operation.location
color = operation.color
reminder_minutes = operation.reminder_minutes
status = operation.status
try:
if operation.action == "create":
if start_at is None or not start_at.strip():
raise ValueError(
"创建日程需要提供 start_at,且必须包含时区偏移"
)
if event_timezone is None or not event_timezone.strip():
raise ValueError("创建日程需要提供 event_timezone")
parsed_start = parse_iso_datetime(start_at)
if parsed_start is None:
raise ValueError(
"创建日程需要提供 start_at,且必须包含时区偏移"
)
parsed_end = parse_iso_datetime(end_at) if end_at else None
created = await service.create_agent_generated(
ScheduleItemCreateRequest(
title=title.strip()
if title and title.strip()
else "新的日程",
description=description.strip()
if description and description.strip()
else None,
start_at=parsed_start,
end_at=parsed_end,
timezone=event_timezone.strip(),
metadata=build_schedule_metadata(
location,
color,
cast(int | None, reminder_minutes),
),
)
)
success_count += 1
result_items.append(
{
"status": "success",
"eventId": str(created.id),
}
)
success_event_ids.append(str(created.id))
continue
if operation.action == "update":
if event_id is None or not event_id.strip():
raise ValueError("更新日程需要提供 event_id")
parsed_event_id = UUID(event_id)
update_data: dict[str, Any] = {}
if title is not None:
update_data["title"] = title.strip()
if description is not None:
update_data["description"] = description.strip()
if start_at:
update_data["start_at"] = parse_iso_datetime(start_at)
if end_at:
update_data["end_at"] = parse_iso_datetime(end_at)
if event_timezone is not None:
timezone_value = event_timezone.strip()
if not timezone_value:
raise ValueError("event_timezone 不能为空")
update_data["timezone"] = timezone_value
if status:
update_data["status"] = ScheduleItemStatus(status)
if location or color or reminder_minutes is not None:
existing = await service.get_by_id(parsed_event_id)
update_data["metadata"] = merge_schedule_metadata_for_update(
existing_metadata=existing.metadata,
location=cast(str | None, location),
color=cast(str | None, color),
reminder_minutes=cast(int | None, reminder_minutes),
)
changed_fields = sorted(update_data.keys())
updated = await service.update(
parsed_event_id,
ScheduleItemUpdateRequest.model_validate(update_data),
)
success_count += 1
result_items.append(
{
"status": "success",
"eventId": str(updated.id),
"changedFields": changed_fields,
}
)
success_event_ids.append(str(updated.id))
continue
if operation.action == "delete":
if event_id is None or not event_id.strip():
raise ValueError("删除日程需要提供 event_id")
await service.delete(UUID(event_id))
success_count += 1
result_items.append(
{
"status": "success",
"eventId": event_id,
}
)
success_event_ids.append(event_id)
continue
except Exception as exc:
code, message, _ = map_calendar_exception(exc)
failed_count += 1
result_items.append(
{
"status": "failure",
"eventId": event_id,
"code": code,
"message": message,
}
)
if failed_count == 0:
final_status = ToolStatus.SUCCESS
summary = (
f"status=success success={success_count} failed={failed_count} "
f"ids=[{','.join(success_event_ids)}]"
)
elif success_count == 0:
final_status = ToolStatus.FAILURE
summary = f"status=failure success={success_count} failed={failed_count}"
else:
final_status = ToolStatus.PARTIAL
summary = (
f"status=partial success={success_count} failed={failed_count} "
f"ids=[{','.join(success_event_ids)}]"
)
compact_items = ",".join(
[
"{"
f"status={item.get('status')},"
f"eventId={item.get('eventId')},code={item.get('code')},"
f"changedFields={item.get('changedFields')}"
"}"
for item in result_items
]
)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if final_status == ToolStatus.FAILURE:
first_failure = next(
(
item
for item in result_items
if isinstance(item, dict) and item.get("status") == "failure"
),
None,
)
error_info = ErrorInfo(
code=str(
first_failure.get("code") if first_failure else "BATCH_FAILED"
),
message=str(
first_failure.get("message")
if first_failure and first_failure.get("message")
else summary
),
retryable=False,
details={"results": result_items},
)
summary = (
f"{summary} first_error_code={error_info.code} "
f"first_error_message={error_info.message}"
)
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=final_status,
result=summary,
error=error_info,
)
)
except Exception as exc:
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
)
async def calendar_share(
event_id: Annotated[
str,
Field(description="Target event ID (UUID string)."),
],
invitees: Annotated[
list[CalendarShareInvitee],
Field(
description=(
"Invitee list with phone and per-user permissions. "
"Prefer composing with user_lookup tool to pick a friend phone first."
),
min_length=1,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Share a calendar event with invitee phones.
Input contract:
- invitees use `phone` (not `userId`)
- phone accepts local/86/E.164 forms and is normalized before share
Orchestration contract:
- prefer `user_lookup` first to get friend candidates
- choose matched friend phone(s)
- call `calendar_share`
Output contract:
- status can be success / partial / failure
- result includes per-item outcomes in `items=[{phone,status,code}]`
- first failure is exposed in `error` when any item fails
Args:
event_id: Target event id as UUID string.
invitees: Invitee list with phone and per-user permissions.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_name = "calendar_share"
try:
parsed_args = CalendarShareArgs.model_validate(
{"event_id": event_id, "invitees": invitees}
)
except Exception as exc: # noqa: BLE001
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args={"event_id": event_id, "invitees": invitees},
code=code,
message=message,
retryable=retryable,
)
tool_call_args = {
"event_id": parsed_args.event_id,
"invitees": [
invitee.model_dump(mode="json", by_alias=True)
for invitee in parsed_args.invitees
],
}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
target_uuid = UUID(parsed_args.event_id)
invited: list[str] = []
result_items: list[dict[str, str]] = []
for invitee in parsed_args.invitees:
raw_phone = invitee.phone.strip()
normalized_phone = raw_phone
for separator in (" ", "-", "(", ")"):
normalized_phone = normalized_phone.replace(separator, "")
if normalized_phone.startswith("0086"):
normalized_phone = f"+86{normalized_phone[4:]}"
elif normalized_phone.startswith("86") and normalized_phone[2:].isdigit():
normalized_phone = f"+{normalized_phone}"
elif normalized_phone.startswith("1") and normalized_phone.isdigit():
normalized_phone = f"+86{normalized_phone}"
if (
len(normalized_phone) != 14
or not normalized_phone.startswith("+861")
or not normalized_phone[1:].isdigit()
or normalized_phone[4] not in {"3", "4", "5", "6", "7", "8", "9"}
):
result_items.append(
{
"phone": raw_phone,
"status": "failure",
"code": "INVALID_ARGUMENT",
"message": "无效手机号格式",
}
)
continue
permission = {
"permission_view": invitee.permission_view,
"permission_edit": invitee.permission_edit,
"permission_invite": invitee.permission_invite,
}
try:
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,
}
)
failure_count = len(
[item for item in result_items if item["status"] == "failure"]
)
success_count = len(invited)
if success_count and failure_count:
final_status = ToolStatus.PARTIAL
elif success_count:
final_status = ToolStatus.SUCCESS
else:
final_status = ToolStatus.FAILURE
compact_items = ",".join(
[
"{"
f"phone={item.get('phone')},status={item.get('status')},"
f"code={item.get('code', '')}"
"}"
for item in result_items
]
)
summary = (
f"status={final_status.value} success={success_count} "
f"failed={failure_count}"
)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failure_count:
first_failure = next(
(item for item in result_items if item.get("status") == "failure"),
None,
)
error_info = ErrorInfo(
code=str(
first_failure.get("code") if first_failure else "INTERNAL_ERROR"
),
message=str(
first_failure.get("message")
if first_failure and first_failure.get("message")
else "日历分享失败"
),
retryable=False,
details={"results": result_items},
)
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=final_status,
result=summary,
error=error_info,
)
)
except Exception as exc:
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
)
@@ -1,504 +0,0 @@
from copy import deepcopy
from typing import Annotated, Any, cast
from uuid import UUID
from agentscope.tool import ToolResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.tool_call_context import get_current_tool_call_id
from core.agentscope.tools.utils.memory_domain import (
create_memories_service,
map_memory_exception,
)
from core.agentscope.tools.utils.tool_response_builder import (
build_error_output,
build_tool_response,
)
from schemas.enums import MemoryType
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent
class MemoryWriteArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
memory_type: MemoryType = MemoryType.USER
user_content: UserMemoryContent | None = None
work_content: WorkProfileContent | None = None
@model_validator(mode="after")
def validate_content(self) -> "MemoryWriteArgs":
if self.memory_type == MemoryType.USER:
if self.user_content is None or self.work_content is not None:
raise ValueError("memory_type=user requires user_content only")
else:
if self.work_content is None or self.user_content is not None:
raise ValueError("memory_type=work requires work_content only")
return self
class MemoryWriteBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[MemoryWriteArgs] = Field(min_length=1, max_length=20)
class MemoryForgetArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
memory_type: MemoryType = MemoryType.USER
forget_paths: list[str] = Field(min_length=1, max_length=100)
@model_validator(mode="after")
def validate_forget_paths(self) -> "MemoryForgetArgs":
allowed_roots = (
set(UserMemoryContent.model_fields)
if self.memory_type == MemoryType.USER
else set(WorkProfileContent.model_fields)
)
normalized: list[str] = []
for raw_path in self.forget_paths:
path = raw_path.strip()
if not path:
continue
parts = [part for part in path.split(".") if part]
if not parts:
continue
if len(parts) > 5:
raise ValueError("forget path depth exceeds limit")
if parts[0] not in allowed_roots:
raise ValueError("forget path root is not allowed")
normalized.append(path)
if not normalized:
raise ValueError("forget_paths cannot be empty")
self.forget_paths = normalized
return self
class MemoryForgetBatchArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
operations: list[MemoryForgetArgs] = Field(min_length=1, max_length=20)
def _memory_error_output(
*,
tool_name: str,
tool_call_args: dict[str, Any],
code: str,
message: str,
retryable: bool,
) -> ToolResponse:
output = build_error_output(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
code=code,
message=message,
retryable=retryable,
)
output = output.model_copy(update={"tool_call_args": tool_call_args})
return build_tool_response(output)
def _validate_runtime_context(
*,
tool_name: str,
tool_call_args: dict[str, Any],
session: Any,
owner_id: Any,
) -> ToolResponse | None:
if session is None or owner_id is None:
return _memory_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="MISSING_RUNTIME_ARGS",
message="记忆工具缺少运行时参数",
retryable=False,
)
return None
def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
merged = deepcopy(base)
for key, value in patch.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = _deep_merge_dict(cast(dict[str, Any], merged[key]), value)
else:
merged[key] = value
return merged
def _remove_content_paths(
base_payload: dict[str, Any],
paths: list[str],
) -> tuple[dict[str, Any], list[str]]:
result = deepcopy(base_payload)
removed: list[str] = []
for raw_path in paths:
path = raw_path.strip()
if not path:
continue
keys = [part for part in path.split(".") if part]
if not keys:
continue
if _delete_nested_path(result, keys):
removed.append(path)
return result, removed
def _delete_nested_path(payload: dict[str, Any], keys: list[str]) -> bool:
current: dict[str, Any] = payload
for key in keys[:-1]:
next_value = current.get(key)
if not isinstance(next_value, dict):
return False
current = next_value
leaf = keys[-1]
if leaf in current:
del current[leaf]
return True
return False
def _compact_result_items(items: list[dict[str, object]]) -> str:
return ",".join(
"{" + ",".join(f"{key}={value}" for key, value in item.items()) + "}"
for item in items
)
async def memory_write(
operations: Annotated[
list[MemoryWriteArgs],
Field(
description=(
"Batch memory write operations. Each item must include memory_type and "
"the matching content object (user_content or work_content)."
),
min_length=1,
max_length=20,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Merge structured facts into user/work memory.
Args:
memory_type: Target memory domain, either ``user`` or ``work``.
user_content: Partial user-memory payload when ``memory_type='user'``.
work_content: Partial work-memory payload when ``memory_type='work'``.
Runtime:
``session`` and ``owner_id`` are injected by toolkit preset kwargs.
Returns:
ToolResponse wrapping ToolAgentOutput.
- success: ``result`` contains a compact status summary.
- failure: ``error`` contains structured code/message/retryable metadata.
"""
tool_name = "memory_write"
tool_call_args: dict[str, Any] = {"operations": operations}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
parsed_batch = MemoryWriteBatchArgs.model_validate(tool_call_args)
service = create_memories_service(
session=cast(AsyncSession, session),
owner_id=cast(UUID, owner_id),
)
success_count = 0
failed_count = 0
updated_types: list[str] = []
failed_operations: list[dict[str, object]] = []
result_items: list[dict[str, object]] = []
for idx, op in enumerate(parsed_batch.operations):
try:
existing = await service.get_memory_model(memory_type=op.memory_type)
if op.memory_type == MemoryType.USER:
base_model = (
UserMemoryContent.model_validate(existing.content)
if existing is not None
else UserMemoryContent()
)
patch_model = cast(UserMemoryContent, op.user_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = UserMemoryContent.model_validate(merged)
updated = await service.update_user_memory(content=validated)
else:
base_model = (
WorkProfileContent.model_validate(existing.content)
if existing is not None
else WorkProfileContent()
)
patch_model = cast(WorkProfileContent, op.work_content)
merged = _deep_merge_dict(
base_model.model_dump(),
patch_model.model_dump(exclude_unset=True),
)
validated = WorkProfileContent.model_validate(merged)
updated = await service.update_work_memory(content=validated)
success_count += 1
updated_types.append(op.memory_type.value)
memory_id = str(
getattr(updated, "id", None)
or (getattr(existing, "id", None) if existing is not None else "")
or ""
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"memoryId": memory_id,
}
)
except Exception as exc: # noqa: BLE001
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_operations.append(
{
"memory_type": op.memory_type.value,
"code": code,
"message": message,
"retryable": retryable,
}
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "failure",
"code": code,
}
)
status = (
ToolStatus.SUCCESS
if failed_count == 0
else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL)
)
status_text = (
"success"
if status == ToolStatus.SUCCESS
else ("failure" if status == ToolStatus.FAILURE else "partial")
)
summary = (
f"status={status_text} "
f"success={success_count} failed={failed_count} "
f"updated_types=[{','.join(updated_types)}]"
)
compact_items = _compact_result_items(result_items)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failed_operations:
first = failed_operations[0]
error_info = ErrorInfo(
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
message=str(first.get("message") or "memory batch write failed"),
retryable=bool(first.get("retryable") is True),
details={"failed_operations": failed_operations},
)
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=status,
result=summary,
error=error_info,
)
)
except Exception as exc: # noqa: BLE001
code, message, retryable = map_memory_exception(exc)
return _memory_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
)
async def memory_forget(
operations: Annotated[
list[MemoryForgetArgs],
Field(
description=(
"Batch memory forget operations. Each item must include memory_type and "
"forget_paths."
),
min_length=1,
max_length=20,
),
],
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Forget selected paths from user/work memory content.
Args:
memory_type: Target memory domain, either ``user`` or ``work``.
forget_paths: Dot-path list to remove from memory content.
Notes:
- Path root must belong to the target memory schema.
- The tool is idempotent; missing paths are skipped safely.
Runtime:
``session`` and ``owner_id`` are injected by toolkit preset kwargs.
Returns:
ToolResponse wrapping ToolAgentOutput with compact execution summary.
"""
tool_name = "memory_forget"
tool_call_args: dict[str, Any] = {"operations": operations}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
parsed_batch = MemoryForgetBatchArgs.model_validate(tool_call_args)
service = create_memories_service(
session=cast(AsyncSession, session),
owner_id=cast(UUID, owner_id),
)
success_count = 0
failed_count = 0
forgotten_total = 0
processed_types: list[str] = []
failed_operations: list[dict[str, object]] = []
result_items: list[dict[str, object]] = []
for idx, op in enumerate(parsed_batch.operations):
try:
existing = await service.get_memory_model(memory_type=op.memory_type)
if existing is None:
success_count += 1
processed_types.append(op.memory_type.value)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"forgotten": 0,
"memoryId": "",
}
)
continue
if op.memory_type == MemoryType.USER:
base_model = UserMemoryContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
op.forget_paths,
)
validated = UserMemoryContent.model_validate(updated_dict)
await service.update_user_memory(content=validated)
else:
base_model = WorkProfileContent.model_validate(existing.content)
updated_dict, removed_paths = _remove_content_paths(
base_model.model_dump(),
op.forget_paths,
)
validated = WorkProfileContent.model_validate(updated_dict)
await service.update_work_memory(content=validated)
forgotten_total += len(removed_paths)
success_count += 1
processed_types.append(op.memory_type.value)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "success",
"forgotten": len(removed_paths),
"memoryId": str(getattr(existing, "id", "") or ""),
}
)
except Exception as exc: # noqa: BLE001
failed_count += 1
code, message, retryable = map_memory_exception(exc)
failed_operations.append(
{
"memory_type": op.memory_type.value,
"code": code,
"message": message,
"retryable": retryable,
}
)
result_items.append(
{
"idx": idx,
"memoryType": op.memory_type.value,
"status": "failure",
"code": code,
}
)
status = (
ToolStatus.SUCCESS
if failed_count == 0
else (ToolStatus.FAILURE if success_count == 0 else ToolStatus.PARTIAL)
)
status_text = (
"success"
if status == ToolStatus.SUCCESS
else ("failure" if status == ToolStatus.FAILURE else "partial")
)
summary = (
f"status={status_text} "
f"success={success_count} failed={failed_count} "
f"forgotten={forgotten_total} "
f"processed_types=[{','.join(processed_types)}]"
)
compact_items = _compact_result_items(result_items)
if compact_items:
summary = f"{summary} items=[{compact_items}]"
error_info: ErrorInfo | None = None
if failed_operations:
first = failed_operations[0]
error_info = ErrorInfo(
code=str(first.get("code") or "MEMORY_BATCH_FAILED"),
message=str(first.get("message") or "memory batch forget failed"),
retryable=bool(first.get("retryable") is True),
details={"failed_operations": failed_operations},
)
return build_tool_response(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=get_current_tool_call_id(tool_name=tool_name),
tool_call_args=tool_call_args,
status=status,
result=summary,
error=error_info,
)
)
except Exception as exc: # noqa: BLE001
code, message, retryable = map_memory_exception(exc)
return _memory_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
)
@@ -1,178 +0,0 @@
from typing import Any, cast
from uuid import UUID
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from agentscope.tool import ToolResponse
from core.agentscope.tools.tool_call_context import get_current_tool_call_id
from core.agentscope.tools.utils.tool_response_builder import (
build_error_output,
build_tool_response,
)
from models.friendships import Friendship
from models.profile import Profile
from schemas.enums import FriendshipStatus
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from v1.auth.gateway import SupabaseAuthGateway
from v1.users.contact_resolver import resolve_contacts_by_user_ids
def _dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
return build_tool_response(output)
def _lookup_error_output(
*,
tool_call_args: dict[str, Any],
code: str,
message: str,
retryable: bool,
) -> ToolResponse:
output = build_error_output(
tool_name="user_lookup",
tool_call_id=get_current_tool_call_id(tool_name="user_lookup"),
code=code,
message=message,
retryable=retryable,
)
output = output.model_copy(update={"tool_call_args": tool_call_args})
return _dump_tool_output(output)
async def _list_friend_contacts(
*,
session: AsyncSession,
owner_id: UUID,
) -> list[dict[str, str]]:
"""Load accepted friends and return contact tuples.
Returns items shaped as:
- userId: friend user UUID string
- username: friend username
- phone: friend phone in E.164 format
"""
friendships_stmt = (
select(Friendship)
.where(
or_(
Friendship.user_low_id == owner_id,
Friendship.user_high_id == owner_id,
)
)
.where(Friendship.status == FriendshipStatus.ACCEPTED)
.where(Friendship.deleted_at.is_(None))
)
friendships = (await session.execute(friendships_stmt)).scalars().all()
friend_ids: list[UUID] = []
for friendship in friendships:
friend_id = (
friendship.user_high_id
if friendship.user_low_id == owner_id
else friendship.user_low_id
)
friend_ids.append(friend_id)
if not friend_ids:
return []
profiles_stmt = (
select(Profile)
.where(Profile.id.in_(friend_ids))
.where(Profile.deleted_at.is_(None))
)
profiles = (await session.execute(profiles_stmt)).scalars().all()
profiles_by_id = {profile.id: profile for profile in profiles}
auth_gateway = SupabaseAuthGateway()
resolved_contacts = await resolve_contacts_by_user_ids(
user_ids=friend_ids,
profiles_by_id=profiles_by_id,
auth_gateway=auth_gateway,
)
contacts: list[dict[str, str]] = []
for friend_id in friend_ids:
contact = resolved_contacts.get(friend_id)
if contact is None:
continue
phone = contact.phone
if not phone:
continue
contacts.append(
{
"userId": str(friend_id),
"username": str(contact.username or ""),
"phone": phone,
}
)
contacts.sort(key=lambda item: (item["username"], item["phone"]))
return contacts
async def user_lookup(
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""List current user's accepted friend contacts.
This tool is intentionally argument-free for business inputs. Runtime
context (`session`, `owner_id`) is injected by toolkit preset kwargs.
Intended composition:
1) call `user_lookup` to obtain friend username/phone candidates
2) resolve target friend from user utterance
3) call `calendar_share` with selected phone(s)
Result format (in ToolAgentOutput.result):
- status=success
- friends_count=<n>
- friends=[{userId=...,username=...,phone=...}, ...]
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_call_args: dict[str, Any] = {}
if session is None or owner_id is None:
return _lookup_error_output(
tool_call_args=tool_call_args,
code="MISSING_RUNTIME_ARGS",
message="用户查找工具缺少运行时参数",
retryable=False,
)
try:
contacts = await _list_friend_contacts(
session=cast(AsyncSession, session),
owner_id=cast(UUID, owner_id),
)
compact_items = ",".join(
[
"{"
f"userId={item.get('userId')},"
f"username={item.get('username')},"
f"phone={item.get('phone')}"
"}"
for item in contacts
]
)
summary = f"status=success friends_count={len(contacts)}"
if compact_items:
summary = f"{summary} friends=[{compact_items}]"
return _dump_tool_output(
ToolAgentOutput(
tool_name="user_lookup",
tool_call_id=get_current_tool_call_id(tool_name="user_lookup"),
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result=summary,
)
)
except Exception as exc:
return _lookup_error_output(
tool_call_args=tool_call_args,
code="INTERNAL_ERROR",
message=f"好友查找失败: {str(exc)}",
retryable=True,
)
@@ -0,0 +1,2 @@
from core.agentscope.tools.internal.project_cli import make_project_cli_wrapper as make_project_cli_wrapper
from core.agentscope.tools.internal.view_skill_file import make_view_skill_file_wrapper as make_view_skill_file_wrapper
@@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Any
from agentscope.tool import ToolResponse
from core.agentscope.tools.cli import invoke_cli_tool
PROJECT_CLI_TOOL_NAME = "project_cli"
def make_project_cli_wrapper(*, allowed_commands: set[str]) -> Any:
async def _project_cli(
command: str,
subcommand: str,
args: dict[str, Any] | None = None,
) -> ToolResponse:
tool_call_args = {
"command": command,
"subcommand": subcommand,
"args": args or {},
}
return await invoke_cli_tool(
tool_name=PROJECT_CLI_TOOL_NAME,
tool_call_args=tool_call_args,
allowed_commands=allowed_commands,
)
_project_cli.__name__ = PROJECT_CLI_TOOL_NAME
_project_cli.__doc__ = """Execute CLI commands for calendar, contacts, and memory operations.
Args:
command: The command to execute (calendar, contacts, memory).
subcommand: The subcommand for the operation (read, write, lookup, etc.).
args: Arguments for the command as a JSON object.
Returns:
ToolResponse with the command result.
"""
return _project_cli
@@ -0,0 +1,91 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from agentscope.message import TextBlock
from agentscope.tool import ToolResponse
SKILLS_DIR = Path(__file__).parent.parent / "skills"
VIEW_SKILL_FILE_TOOL_NAME = "view_skill_file"
def make_view_skill_file_wrapper(*, enabled_skill_names: set[str]) -> Any:
skills_root = SKILLS_DIR.resolve()
async def _view_skill_file(
file_path: str,
ranges: list[int] | None = None,
) -> ToolResponse:
normalized = file_path.strip().replace("\\", "/")
if normalized.startswith("/"):
normalized = normalized[1:]
parts = normalized.split("/")
if not parts:
return _error_response("INVALID_PATH", "file_path cannot be empty")
skill_name = parts[0]
if skill_name not in enabled_skill_names:
return _error_response(
"ACCESS_DENIED",
f"skill '{skill_name}' is not enabled. Enabled skills: {sorted(enabled_skill_names)}",
)
target_path = skills_root / normalized
try:
target_path = target_path.resolve()
target_path.relative_to(skills_root)
except Exception:
return _error_response("ACCESS_DENIED", "access denied: path outside skills directory")
if not target_path.exists() or not target_path.is_file():
return _error_response("FILE_NOT_FOUND", f"file not found: {file_path}")
try:
content = target_path.read_text(encoding="utf-8")
except Exception as exc:
return _error_response("READ_ERROR", f"failed to read file: {exc}")
lines = content.splitlines()
if ranges and len(ranges) >= 2:
start = max(1, ranges[0])
end = min(len(lines), ranges[1])
lines = lines[start - 1 : end]
text = "\n".join(lines)
return ToolResponse(
content=[
TextBlock(
type="text",
text=text,
)
]
)
_view_skill_file.__name__ = VIEW_SKILL_FILE_TOOL_NAME
_view_skill_file.__doc__ = """Read skill instruction files within enabled skill directories.
This tool provides progressive disclosure of skill instructions. You should call this tool
to read the SKILL.md file of a skill before using project_cli for that skill's commands.
Args:
file_path: Relative path within the skills directory (e.g., "calendar/SKILL.md").
ranges: Optional [start_line, end_line] to read a specific line range (1-indexed).
Returns:
ToolResponse with the file content.
"""
return _view_skill_file
def _error_response(code: str, message: str) -> ToolResponse:
return ToolResponse(
content=[
TextBlock(
type="text",
text=f"error: {code} - {message}",
)
]
)
@@ -0,0 +1,94 @@
---
name: calendar
description: Calendar event management - read, create, update, delete, and share events.
---
# Calendar Skill
## Execution Protocol
1. On first calendar use in a run, call `view_skill_file` with `calendar/SKILL.md` before any `project_cli` call.
2. After reading, use `project_cli` only with `command="calendar"`.
3. If the user asks for actual schedule data, use `project_cli` to verify it. Do not guess results.
## When to Use
- User asks about their schedule or upcoming events
- User wants to create, update, or delete calendar events
- User wants to share a calendar event with someone
- User asks about event details within a date range
## Available Tool
Use the single tool `project_cli`.
Read this file first with `view_skill_file` when calendar is the relevant skill.
### Read Events
Call `project_cli` with:
```json
{
"command": "calendar",
"subcommand": "read",
"args": {
"start_at": "2026-04-21T00:00:00+08:00",
"end_at": "2026-04-22T00:00:00+08:00"
}
}
```
Use this whenever the user asks what is scheduled, free, upcoming, or happening in a time range.
### Write Events
Call `project_cli` with:
```json
{
"command": "calendar",
"subcommand": "write",
"args": {
"operations": []
}
}
```
Each operation object requires:
- `action`: `create`, `update`, or `delete`
- create requires `start_at`, `event_timezone`
- update/delete require `event_id`
Read first if you need to confirm the write payload shape instead of relying on memory.
### Share Events
Call `project_cli` with:
```json
{
"command": "calendar",
"subcommand": "share",
"args": {
"event_id": "<uuid>",
"invitees": []
}
}
```
## Composition Patterns
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` `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`
## 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`.
@@ -0,0 +1,53 @@
---
name: contacts
description: Contact lookup - find friend information including phone numbers for calendar sharing.
---
# Contacts Skill
## Execution Protocol
1. On first contacts use in a run, call `view_skill_file` with `contacts/SKILL.md` before any `project_cli` call.
2. After reading, use `project_cli` only with `command="contacts"`.
3. If contact data is needed for a later action, fetch it first instead of inventing phone numbers or friend matches.
## 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`
- User asks about their friend list
## Available Tool
Use the single tool `project_cli`.
Read this file first with `view_skill_file` when contacts is the relevant skill.
### Lookup Contacts
Call `project_cli` with:
```json
{
"command": "contacts",
"subcommand": "lookup",
"args": {}
}
```
Returns:
- `friends_count`: Total number of friends
- `friends`: List of `{userId, username, phone}`
## Composition Patterns
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
- Match user's description to a friend
- Call `project_cli` `calendar share` with the friend's phone
## Failure Recovery
- If no friends found, inform the user they have no contacts yet
- If lookup fails, suggest retrying
@@ -0,0 +1,68 @@
---
name: memory
description: User memory management - store and forget personal facts and work profile information.
---
# Memory Skill
## Execution Protocol
1. On first memory use in a run, call `view_skill_file` with `memory/SKILL.md` before any `project_cli` call.
2. After reading, use `project_cli` only with `command="memory"`.
3. If the user asks to remember or forget something, execute `project_cli`; do not claim persistence without the tool result.
## When to Use
- User shares personal preferences, habits, or facts they want remembered
- User wants to update their work profile (company, role, skills)
- User wants to remove previously stored information
- Agent needs to recall user preferences for personalization
## Available Tool
Use the single tool `project_cli`.
Read this file first with `view_skill_file` when memory is the relevant skill.
### Write Memory
Call `project_cli` with:
```json
{
"command": "memory",
"subcommand": "write",
"args": {
"operations": []
}
}
```
Operation objects use `memory_type` (`user` or `work`) plus matching content.
### Forget Memory
Call `project_cli` with:
```json
{
"command": "memory",
"subcommand": "forget",
"args": {
"operations": []
}
}
```
## Composition Patterns
1. When user says "remember that I prefer morning meetings":
- Call `project_cli` `memory write` with `memory_type=user` and appropriate content
2. When user says "forget my old address":
- Call `project_cli` `memory forget` with the specific dot-path
## Failure Recovery
- If write fails, inform the user and suggest rephrasing
- If forget path is invalid, suggest checking the data structure
@@ -1,6 +1,7 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from typing import Any
from uuid import uuid4
_CURRENT_TOOL_CALL_ID: ContextVar[str | None] = ContextVar(
@@ -8,6 +9,11 @@ _CURRENT_TOOL_CALL_ID: ContextVar[str | None] = ContextVar(
default=None,
)
_TOOL_AGENT_OUTPUT_STORE: ContextVar[dict[str, dict[str, Any]] | None] = ContextVar(
"tool_agent_output_store",
default=None,
)
def set_current_tool_call_id(tool_call_id: str | None) -> Token[str | None]:
return _CURRENT_TOOL_CALL_ID.set(tool_call_id)
@@ -22,3 +28,30 @@ def get_current_tool_call_id(*, tool_name: str) -> str:
if isinstance(current, str) and current.strip():
return current.strip()
return f"{tool_name}-call-{uuid4().hex}"
def _ensure_store() -> dict[str, dict[str, Any]]:
store = _TOOL_AGENT_OUTPUT_STORE.get()
if store is None:
store = {}
_TOOL_AGENT_OUTPUT_STORE.set(store)
return store
def store_tool_agent_output(*, tool_call_id: str, payload: dict[str, Any]) -> None:
store = _ensure_store()
store[tool_call_id] = payload
def peek_tool_agent_output(*, tool_call_id: str) -> dict[str, Any] | None:
store = _TOOL_AGENT_OUTPUT_STORE.get()
if store is None:
return None
return store.get(tool_call_id)
def consume_tool_agent_output(*, tool_call_id: str) -> dict[str, Any] | None:
store = _TOOL_AGENT_OUTPUT_STORE.get()
if store is None:
return None
return store.pop(tool_call_id, None)
@@ -1,16 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class AgentTool(str, Enum):
CALENDAR_READ = "calendar.read"
CALENDAR_WRITE = "calendar.write"
CALENDAR_SHARE = "calendar.share"
USER_LOOKUP = "user.lookup"
MEMORY_WRITE = "memory.write"
MEMORY_FORGET = "memory.forget"
@dataclass(frozen=True)
@@ -25,68 +15,12 @@ class ToolConfig:
TOOL_CONFIGS: dict[str, ToolConfig] = {
"calendar_read": ToolConfig(
name="calendar_read",
"project_cli": ToolConfig(
name="project_cli",
approval=ToolApprovalConfig(required=False),
),
"user_lookup": ToolConfig(
name="user_lookup",
approval=ToolApprovalConfig(required=False),
),
"calendar_write": ToolConfig(
name="calendar_write",
approval=ToolApprovalConfig(required=False),
),
"calendar_share": ToolConfig(
name="calendar_share",
approval=ToolApprovalConfig(required=False),
),
"memory_write": ToolConfig(
name="memory_write",
approval=ToolApprovalConfig(required=False),
),
"memory_forget": ToolConfig(
name="memory_forget",
"view_skill_file": ToolConfig(
name="view_skill_file",
approval=ToolApprovalConfig(required=False),
),
}
AGENT_TOOL_TO_FUNCTION_NAME: dict[AgentTool, str] = {
AgentTool.CALENDAR_READ: "calendar_read",
AgentTool.CALENDAR_WRITE: "calendar_write",
AgentTool.CALENDAR_SHARE: "calendar_share",
AgentTool.USER_LOOKUP: "user_lookup",
AgentTool.MEMORY_WRITE: "memory_write",
AgentTool.MEMORY_FORGET: "memory_forget",
}
TOOL_NAME_ALIASES: dict[str, AgentTool] = {
AgentTool.CALENDAR_READ.value: AgentTool.CALENDAR_READ,
"calendar_read": AgentTool.CALENDAR_READ,
AgentTool.CALENDAR_WRITE.value: AgentTool.CALENDAR_WRITE,
"calendar_write": AgentTool.CALENDAR_WRITE,
AgentTool.CALENDAR_SHARE.value: AgentTool.CALENDAR_SHARE,
"calendar_share": AgentTool.CALENDAR_SHARE,
AgentTool.USER_LOOKUP.value: AgentTool.USER_LOOKUP,
"user_lookup": AgentTool.USER_LOOKUP,
AgentTool.MEMORY_WRITE.value: AgentTool.MEMORY_WRITE,
"memory_write": AgentTool.MEMORY_WRITE,
AgentTool.MEMORY_FORGET.value: AgentTool.MEMORY_FORGET,
"memory_forget": AgentTool.MEMORY_FORGET,
}
def parse_agent_tool(value: object) -> AgentTool:
if isinstance(value, AgentTool):
return value
raw_value = str(value or "").strip().lower()
if not raw_value:
raise ValueError("enabled tool value cannot be empty")
tool = TOOL_NAME_ALIASES.get(raw_value)
if tool is None:
raise ValueError(f"unknown enabled tool: {raw_value}")
return tool
def resolve_tool_function_names(tools: set[AgentTool]) -> set[str]:
return {AGENT_TOOL_TO_FUNCTION_NAME[tool] for tool in tools}
@@ -1,42 +1,18 @@
from __future__ import annotations
from typing import Any, AsyncGenerator, Callable
from uuid import uuid4
from core.agentscope.tools.tool_call_context import (
reset_current_tool_call_id,
set_current_tool_call_id,
)
from core.agentscope.tools.tool_config import (
AGENT_TOOL_TO_FUNCTION_NAME,
TOOL_CONFIGS,
ToolConfig,
parse_agent_tool,
)
from core.agentscope.tools.utils.tool_response_builder import (
build_error_response,
)
def register_tool_middlewares(
*,
toolkit: Any,
config_by_name: dict[str, ToolConfig] | None = None,
meta_by_name: dict[str, ToolConfig] | None = None,
approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None]
| None = None,
) -> None:
effective_config = config_by_name or meta_by_name or TOOL_CONFIGS
toolkit.register_middleware(create_tool_call_context_middleware())
toolkit.register_middleware(
create_approval_middleware(
config_by_name=effective_config,
approval_resolver=approval_resolver,
)
)
def register_tool_middlewares(*, toolkit: Any) -> None:
toolkit.register_middleware(_create_tool_call_context_middleware())
def create_tool_call_context_middleware() -> Callable[..., AsyncGenerator[Any, None]]:
def _create_tool_call_context_middleware() -> Callable[..., AsyncGenerator[Any, None]]:
async def tool_call_context_middleware(
kwargs: dict[str, Any],
next_handler: Callable[..., Any],
@@ -56,98 +32,3 @@ def create_tool_call_context_middleware() -> Callable[..., AsyncGenerator[Any, N
reset_current_tool_call_id(token)
return tool_call_context_middleware
def create_approval_middleware(
*,
config_by_name: dict[str, ToolConfig],
approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None]
| None = None,
) -> Callable[..., AsyncGenerator[Any, None]]:
def _resolve_tool_config(*, tool_name: str) -> ToolConfig | None:
config = config_by_name.get(tool_name)
if config is not None:
return config
try:
normalized_tool_name = AGENT_TOOL_TO_FUNCTION_NAME[
parse_agent_tool(tool_name)
]
except ValueError:
return None
return config_by_name.get(normalized_tool_name)
def _resolve_tool_call_id(tool_call: dict[str, Any]) -> str:
raw_tool_call_id = tool_call.get("id")
if isinstance(raw_tool_call_id, str) and raw_tool_call_id.strip():
return raw_tool_call_id.strip()
return f"tool-call-{uuid4().hex}"
async def approval_middleware(
kwargs: dict[str, Any],
next_handler: Callable[..., Any],
) -> AsyncGenerator[Any, None]:
tool_call = kwargs.get("tool_call")
if not isinstance(tool_call, dict):
async for response in await next_handler(**kwargs):
yield response
return
tool_name = tool_call.get("name")
if not isinstance(tool_name, str):
async for response in await next_handler(**kwargs):
yield response
return
config = _resolve_tool_config(tool_name=tool_name)
if config is None or not config.approval.required:
async for response in await next_handler(**kwargs):
yield response
return
tool_input = tool_call.get("input")
tool_args = tool_input if isinstance(tool_input, dict) else {}
decision = (
approval_resolver(tool_name, tool_args, config)
if approval_resolver
else None
)
if decision == "approved":
sanitized_args = {
key: value for key, value in tool_args.items() if key != "_hitl"
}
next_call = {**tool_call, "input": sanitized_args}
next_kwargs = {**kwargs, "tool_call": next_call}
async for response in await next_handler(**next_kwargs):
yield response
return
if decision == "rejected":
content = build_error_response(
tool_name=tool_name,
tool_call_id=_resolve_tool_call_id(tool_call),
code="TOOL_REJECTED",
message=f"工具 {tool_name} 的调用已被审核拒绝",
retryable=False,
details={
"tool": tool_name,
"status": "rejected",
},
)
yield content
return
pending_response = build_error_response(
tool_name=tool_name,
tool_call_id=_resolve_tool_call_id(tool_call),
code="TOOL_PENDING_APPROVAL",
message=f"工具 {tool_name} 需要审核批准",
retryable=True,
details={
"tool": tool_name,
"status": "pending",
},
)
yield pending_response
return approval_middleware
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import Any
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
def _resolve_command_key(tool_output: ToolAgentOutput) -> tuple[str, str] | None:
args = tool_output.tool_call_args or {}
command = str(args.get("command", "")).strip()
subcommand = str(args.get("subcommand", "")).strip()
if command and subcommand:
return command, subcommand
result = tool_output.result
if isinstance(result, dict):
command = str(result.get("command", "")).strip()
subcommand = str(result.get("subcommand", "")).strip()
if command and subcommand:
return command, subcommand
return None
def _result_data(tool_output: ToolAgentOutput) -> dict[str, Any] | None:
result = tool_output.result
if not isinstance(result, dict):
return None
data = result.get("data")
return data if isinstance(data, dict) else None
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)}
def _calendar_write_ui_hints(tool_output: ToolAgentOutput) -> 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", []),
}
def _calendar_share_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", []),
}
def _contacts_lookup_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)}
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] = {
("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,
}
def postprocess_tool_output(tool_output: ToolAgentOutput) -> ToolAgentOutput:
if tool_output.status == ToolStatus.FAILURE:
return tool_output
if tool_output.ui_hints is not None:
return tool_output
command_key = _resolve_command_key(tool_output)
if command_key is None:
return tool_output
builder = _UI_HINTS_BUILDERS.get(command_key)
if builder is None:
return tool_output
ui_hints = builder(tool_output)
if ui_hints is None:
return tool_output
return tool_output.model_copy(update={"ui_hints": ui_hints})
@@ -1,57 +0,0 @@
from __future__ import annotations
import json
from typing import Protocol
from services.base.supabase import supabase_service
class ToolResultStorage(Protocol):
async def upload_json(
self,
*,
bucket: str,
path: str,
payload: dict[str, object],
) -> str: ...
async def read_json(
self,
*,
bucket: str,
path: str,
) -> dict[str, object] | None: ...
class SupabaseToolResultStorage:
async def upload_json(
self,
*,
bucket: str,
path: str,
payload: dict[str, object],
) -> str:
serialized = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
await supabase_service.upload_bytes(
bucket=bucket,
path=path,
content=serialized.encode("utf-8"),
content_type="application/json",
)
return path
async def read_json(
self,
*,
bucket: str,
path: str,
) -> dict[str, object] | None:
raw = await supabase_service.download_bytes(bucket=bucket, path=path)
decoded = json.loads(raw.decode("utf-8"))
if isinstance(decoded, dict):
return decoded
return None
def create_tool_result_storage() -> ToolResultStorage:
return SupabaseToolResultStorage()
+53 -79
View File
@@ -1,110 +1,84 @@
from __future__ import annotations
from typing import Any, cast
from uuid import UUID
from pathlib import Path
from typing import Any
from agentscope.tool import Toolkit
from agentscope.types import JSONSerializableObject
from core.agentscope.tools.custom.calendar import (
calendar_read,
calendar_share,
calendar_write,
)
from core.agentscope.tools.custom.memory import (
memory_forget,
memory_write,
)
from core.agentscope.tools.custom.user_lookup import user_lookup
from core.agentscope.tools.tool_config import (
TOOL_CONFIGS,
)
from core.agentscope.tools.internal import make_project_cli_wrapper, make_view_skill_file_wrapper
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 sqlalchemy.ext.asyncio import AsyncSession
from schemas.agent.system_agent import AgentType
from core.logging import get_logger
from schemas.agent.skill_config import SkillName
TOOL_FUNCTIONS: dict[str, Any] = {
"calendar_read": calendar_read,
"calendar_write": calendar_write,
"calendar_share": calendar_share,
"user_lookup": user_lookup,
"memory_write": memory_write,
"memory_forget": memory_forget,
}
_logger = get_logger("core.agentscope.tools.toolkit")
SKILLS_DIR = Path(__file__).parent / "skills"
AGENT_TYPE_TO_DEFAULT_TOOLS: dict[AgentType, set[str]] = {
AgentType.WORKER: {
"calendar_read",
"calendar_write",
"calendar_share",
"user_lookup",
},
}
def _all_skill_names() -> set[str]:
return {skill.value for skill in SkillName}
def _validate_enabled_tool_names(enabled_tool_names: set[str]) -> set[str]:
unknown = enabled_tool_names - set(TOOL_FUNCTIONS)
def _validate_enabled_skill_names(skill_names: set[str]) -> set[str]:
unknown = skill_names - _all_skill_names()
if unknown:
raise ValueError(f"unknown tools in enabled_tool_names: {sorted(unknown)}")
return enabled_tool_names
raise ValueError(f"unknown skills in enabled_skill_names: {sorted(unknown)}")
return skill_names
def build_toolkit(
*,
session: AsyncSession,
owner_id: UUID,
enabled_tool_names: set[str] | None = None,
enabled_skill_names: set[str] | None = None,
enable_hitl: bool | None = None,
):
toolkit = Toolkit()
if enabled_tool_names is None:
enabled_names = set(TOOL_FUNCTIONS)
else:
enabled_names = _validate_enabled_tool_names(set(enabled_tool_names))
) -> Any:
from agentscope.tool import Toolkit
preset_kwargs = cast(
dict[str, JSONSerializableObject],
{
"session": session,
"owner_id": owner_id,
},
if enabled_skill_names is None:
enabled_skills = _all_skill_names()
else:
enabled_skills = _validate_enabled_skill_names(enabled_skill_names)
toolkit = Toolkit()
allowed_commands = enabled_skills
project_cli_wrapper = make_project_cli_wrapper(allowed_commands=allowed_commands)
toolkit.register_tool_function(
project_cli_wrapper,
func_name=PROJECT_CLI_TOOL_NAME,
)
for tool_name in sorted(enabled_names):
tool_func = TOOL_FUNCTIONS[tool_name]
toolkit.register_tool_function(
tool_func,
func_name=tool_name,
preset_kwargs=preset_kwargs,
)
view_skill_wrapper = make_view_skill_file_wrapper(enabled_skill_names=enabled_skills)
toolkit.register_tool_function(
view_skill_wrapper,
func_name=VIEW_SKILL_FILE_TOOL_NAME,
)
for skill_name in enabled_skills:
skill_dir = SKILLS_DIR / skill_name
if skill_dir.is_dir():
try:
toolkit.register_agent_skill(str(skill_dir))
except Exception as exc:
_logger.warning(
"failed_to_register_skill",
skill_name=skill_name,
error=str(exc),
)
approval_enabled = enable_hitl if enable_hitl is not None else True
if approval_enabled:
register_tool_middlewares(toolkit=toolkit, config_by_name=TOOL_CONFIGS)
register_tool_middlewares(toolkit=toolkit)
return toolkit
def build_stage_toolkit(
*,
agent_type: AgentType,
session: AsyncSession,
owner_id: UUID,
enabled_tool_names: set[str] | None = None,
enabled_skill_names: set[str] | None = None,
enable_hitl: bool | None = None,
):
default_tools = AGENT_TYPE_TO_DEFAULT_TOOLS.get(agent_type)
if default_tools is None:
raise ValueError(f"unknown agent_type: {agent_type}")
selected_names = (
set(default_tools)
if enabled_tool_names is None
else _validate_enabled_tool_names(set(enabled_tool_names))
)
) -> Any:
return build_toolkit(
session=session,
owner_id=owner_id,
enabled_tool_names=selected_names,
enabled_skill_names=enabled_skill_names,
enable_hitl=enable_hitl,
)
@@ -1,21 +1,20 @@
from __future__ import annotations
import json
from typing import Any
from agentscope.message import TextBlock
from agentscope.tool import ToolResponse
from core.agentscope.utils.parsing import project_tool_result_text
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
def build_tool_response(content: ToolAgentOutput) -> ToolResponse:
"""Wrap ToolAgentOutput into AgentScope ToolResponse."""
payload = content.model_dump(mode="json", exclude_none=True)
text = project_tool_result_text(content.result)
return ToolResponse(
content=[
TextBlock(
type="text",
text=json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
text=text,
)
]
)
@@ -29,12 +28,11 @@ def build_error_output(
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> ToolAgentOutput:
"""Build a ToolAgentOutput in failure status."""
return ToolAgentOutput(
tool_name=tool_name,
tool_call_id=tool_call_id,
status=ToolStatus.FAILURE,
result=f"status=failure code={code} message={message}",
result={"status": "failure", "code": code, "message": message},
error=ErrorInfo(
code=code,
message=message,
@@ -52,7 +50,6 @@ def build_error_response(
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> ToolResponse:
"""Build standardized ToolResponse for error cases."""
return build_tool_response(
build_error_output(
tool_name=tool_name,
+50 -3
View File
@@ -4,10 +4,45 @@ import json
from collections.abc import Sequence
from typing import Any
from core.agentscope.tools.tool_call_context import consume_tool_agent_output
from core.logging import get_logger
from schemas.agent.runtime_models import ToolAgentOutput
_logger = get_logger("core.agentscope.utils.parsing")
def project_tool_result_text(result: Any) -> str:
if result is None:
return ""
if isinstance(result, str):
return result
try:
return json.dumps(result, ensure_ascii=False, separators=(",", ":"))
except Exception:
return str(result)
def parse_tool_agent_output(
output: Any,
*,
tool_call_id: str | None = None,
tool_name: str | None = None,
tool_call_args: dict[str, Any] | None = None,
) -> ToolAgentOutput | None:
side_channel_payload: dict[str, Any] | None = None
if tool_call_id:
side_channel_payload = consume_tool_agent_output(tool_call_id=tool_call_id)
if side_channel_payload is not None:
try:
return ToolAgentOutput.model_validate(side_channel_payload)
except Exception as exc:
_logger.warning(
"parse_tool_agent_output_side_channel_failed",
error=str(exc),
tool_call_id=tool_call_id,
)
def parse_tool_agent_output(output: Any) -> ToolAgentOutput | None:
blocks = output if isinstance(output, Sequence) else []
for block in blocks:
if not isinstance(block, dict) or block.get("type") != "text":
@@ -16,8 +51,20 @@ def parse_tool_agent_output(output: Any) -> ToolAgentOutput | None:
if not isinstance(text, str) or not text.strip():
continue
try:
return ToolAgentOutput.model_validate(json.loads(text))
except Exception:
parsed = json.loads(text)
if tool_name and "tool_name" not in parsed:
parsed["tool_name"] = tool_name
if tool_call_id and "tool_call_id" not in parsed:
parsed["tool_call_id"] = tool_call_id
if tool_call_args and "tool_call_args" not in parsed:
parsed["tool_call_args"] = tool_call_args
return ToolAgentOutput.model_validate(parsed)
except Exception as exc:
_logger.warning(
"parse_tool_agent_output_failed",
error=str(exc),
text_preview=text[:200],
)
return None
return None
@@ -0,0 +1,90 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from core.auth.jwt_verifier import TokenValidationError
_AUDIENCE = "agent-tool-runtime"
_PURPOSE = "agent_tool_runtime"
class ToolCredentialIssuer:
def __init__(
self,
*,
jwt_secret: str,
jwt_algorithm: str,
jwt_issuer: str,
ttl_seconds: int,
) -> None:
if jwt_algorithm != "HS256":
raise TokenValidationError("Unsupported JWT algorithm for tool credential")
self._jwt_secret = jwt_secret
self._jwt_algorithm = jwt_algorithm
self._jwt_issuer = jwt_issuer
self._ttl_seconds = ttl_seconds
def issue(self, *, owner_id: str, mode: str = "chat") -> str:
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": owner_id,
"aud": _AUDIENCE,
"iss": self._jwt_issuer,
"iat": now,
"exp": now + timedelta(seconds=self._ttl_seconds),
"purpose": _PURPOSE,
"mode": mode,
}
return jwt.encode(payload, self._jwt_secret, algorithm=self._jwt_algorithm)
def verify(self, token: str) -> dict[str, Any]:
try:
payload = jwt.decode(
token,
self._jwt_secret,
algorithms=[self._jwt_algorithm],
options={
"require": ["sub", "exp", "aud", "iss", "purpose"],
"verify_aud": True,
},
audience=_AUDIENCE,
)
except jwt.ExpiredSignatureError as exc:
raise TokenValidationError("Tool credential expired") from exc
except jwt.InvalidSignatureError as exc:
raise TokenValidationError("Tool credential signature invalid") from exc
except jwt.InvalidAlgorithmError as exc:
raise TokenValidationError("Tool credential algorithm invalid") from exc
except jwt.DecodeError as exc:
raise TokenValidationError("Tool credential decode failed") from exc
except jwt.PyJWTError as exc:
raise TokenValidationError("Tool credential validation failed") from exc
if payload.get("purpose") != _PURPOSE:
raise TokenValidationError("Tool credential purpose mismatch")
token_issuer = payload.get("iss")
if token_issuer != self._jwt_issuer:
raise TokenValidationError(
f"Tool credential issuer mismatch: expected {self._jwt_issuer}, got {token_issuer}"
)
return payload
def create_credential_issuer() -> ToolCredentialIssuer:
from core.config.settings import config
jwt_secret = config.supabase.jwt_secret
if jwt_secret is None:
raise TokenValidationError("JWT secret not configured for tool credential issuer")
return ToolCredentialIssuer(
jwt_secret=jwt_secret.get_secret_value(),
jwt_algorithm=config.supabase.jwt_algorithm,
jwt_issuer=config.supabase.jwt_issuer or "",
ttl_seconds=config.agent_runtime.tool_credential_ttl_seconds,
)
@@ -0,0 +1,20 @@
from __future__ import annotations
from contextvars import ContextVar, Token
_TOOL_CREDENTIAL: ContextVar[str | None] = ContextVar(
"tool_credential",
default=None,
)
def set_tool_credential(credential: str | None) -> Token[str | None]:
return _TOOL_CREDENTIAL.set(credential)
def reset_tool_credential(token: Token[str | None]) -> None:
_TOOL_CREDENTIAL.reset(token)
def get_tool_credential() -> str | None:
return _TOOL_CREDENTIAL.get()
+1 -5
View File
@@ -63,14 +63,12 @@ async def _dispatch_automation_run(
from ag_ui.core import RunAgentInput
from core.auth.models import CurrentUser
from core.agentscope.tools.tool_result_storage import create_tool_result_storage
from schemas.agent.forwarded_props import RuntimeMode
from v1.agent.dependencies import TaskiqQueueClient, RedisEventStream
from v1.agent.repository import AgentRepository
from v1.agent.service import AgentService
current_user = CurrentUser(id=owner_id)
tool_result_storage = create_tool_result_storage()
run_input = {
"threadId": str(thread_id),
@@ -95,9 +93,7 @@ async def _dispatch_automation_run(
from core.db.session import AsyncSessionLocal
async with AsyncSessionLocal() as session:
repository = AgentRepository(
session=session, tool_result_storage=tool_result_storage
)
repository = AgentRepository(session=session)
service = AgentService(
repository=repository,
queue=TaskiqQueueClient(),
+2 -1
View File
@@ -186,6 +186,7 @@ class AgentRuntimeSettings(BaseModel):
ge=1024,
le=64 * 1024 * 1024,
)
tool_credential_ttl_seconds: int = Field(default=300, ge=30, le=3600)
class AutomationSchedulerSettings(BaseModel):
@@ -251,7 +252,7 @@ class AppVersionSettings(BaseModel):
class TestSettings(BaseModel):
phone: str = ""
password: str = ""
code: str = ""
def _resolve_env_file() -> str:
@@ -19,9 +19,8 @@ input_template: |
表达风格:
- 语言自然、温和、可读,像助理在做每日回顾。
- 结论先行,避免空话,不要输出与任务无关的闲聊内容。
enabled_tools:
- memory.write
- memory.forget
enabled_skills:
- memory
context:
source: latest_chat
window_mode: day
@@ -9,7 +9,7 @@ agents:
context_messages:
mode: day
count: 2
enabled_tools: []
enabled_skills: []
- agent_type: worker
llm_model_code: qwen3.5-flash
@@ -21,8 +21,6 @@ agents:
context_messages:
mode: number
count: 20
enabled_tools:
- calendar.read
- calendar.write
- calendar.share
- user.lookup
enabled_skills:
- calendar
- contacts
+3 -21
View File
@@ -7,21 +7,12 @@ from schemas.agent.forwarded_props import (
from schemas.agent.forwarded_props import RuntimeMode
from schemas.agent.runtime_models import (
AgentOutput,
ConstraintItem,
ExecutionMode,
KeyEntity,
NormalizedTaskInput,
ResultTyping,
ResultType,
ErrorInfo,
RouterAgentOutput,
RunStatus,
TaskType,
TaskTyping,
ToolAgentOutput,
ToolStatus,
WorkerAgentOutputLite,
WorkerAgentOutputRich,
resolve_worker_output_model,
)
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
from schemas.agent.visibility import SystemVisibilityBit, VisibilityMask, bit_mask
@@ -36,19 +27,12 @@ from schemas.agent.ui_hints import (
__all__ = [
"AgentType",
"AgentOutput",
"ConstraintItem",
"ExecutionMode",
"ForwardedPropsPayload",
"KeyEntity",
"NormalizedTaskInput",
"ResultTyping",
"ClientTimeContext",
"ResultType",
"ErrorInfo",
"ForwardedPropsPayload",
"RouterAgentOutput",
"RunStatus",
"RuntimeMode",
"TaskType",
"TaskTyping",
"SystemAgentLLMConfig",
"SystemVisibilityBit",
"ToolAgentOutput",
@@ -60,9 +44,7 @@ __all__ = [
"UiHintsPayload",
"VisibilityMask",
"WorkerAgentOutputLite",
"WorkerAgentOutputRich",
"bit_mask",
"parse_forwarded_props_client_time",
"parse_forwarded_props_runtime_mode",
"resolve_worker_output_model",
]
+13 -128
View File
@@ -3,64 +3,7 @@ from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from schemas.agent.ui_hints import UiHintsPayload
class TaskType(str, Enum):
KNOWLEDGE = "knowledge"
RECOMMENDATION = "recommendation"
PLANNING = "planning"
SCHEDULING = "scheduling"
REMINDER_MANAGEMENT = "reminder_management"
TODO_MANAGEMENT = "todo_management"
COMMUNICATION_DRAFTING = "communication_drafting"
INFORMATION_ORGANIZATION = "information_organization"
STATUS_TRACKING = "status_tracking"
TRANSACTION_ASSIST = "transaction_assist"
ACTION_EXECUTION = "action_execution"
TROUBLESHOOTING = "troubleshooting"
UNKNOWN = "unknown"
class ResultType(str, Enum):
DIRECT_ANSWER = "direct_answer"
OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation"
ACTION_PLAN = "action_plan"
SCHEDULE_PROPOSAL = "schedule_proposal"
TODO_LIST = "todo_list"
DRAFT_MESSAGE = "draft_message"
SUMMARY = "summary"
PROGRESS_SUMMARY = "progress_summary"
DIAGNOSIS_REPORT = "diagnosis_report"
STRUCTURED_PAYLOAD = "structured_payload"
EXECUTION_REPORT = "execution_report"
CLARIFICATION_REQUEST = "clarification_request"
SAFETY_BLOCK = "safety_block"
ERROR_REPORT = "error_report"
UNKNOWN = "unknown"
class TaskTyping(BaseModel):
model_config = ConfigDict(extra="forbid")
primary: TaskType
secondary: list[TaskType] = Field(default_factory=list, max_length=3)
class ResultTyping(BaseModel):
model_config = ConfigDict(extra="forbid")
primary: ResultType
secondary: list[ResultType] = Field(default_factory=list, max_length=3)
class ExecutionMode(str, Enum):
ONESTEP = "onestep"
TOOL_ASSISTED = "tool_assisted"
MULTISTEP = "multistep"
from pydantic import BaseModel, ConfigDict, Field
class RunStatus(str, Enum):
SUCCESS = "success"
@@ -74,59 +17,6 @@ class ToolStatus(str, Enum):
PARTIAL = "partial"
class KeyEntity(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
type: str
value: str | None = None
@field_validator("value", mode="before")
@classmethod
def normalize_value(cls, value: object) -> object:
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, bool | int | float):
return str(value)
return value
class ConstraintItem(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str
value: str
required: bool = True
@field_validator("value", mode="before")
@classmethod
def normalize_value(cls, value: object) -> object:
if isinstance(value, bool | int | float):
return str(value)
return value
class NormalizedTaskInput(BaseModel):
model_config = ConfigDict(extra="forbid")
user_text: str
multimodal_summary: list[str] = Field(default_factory=list)
context_summary: str = Field(default="", max_length=2000)
class RouterAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
normalized_task_input: NormalizedTaskInput
key_entities: list[KeyEntity] = Field(default_factory=list)
constraints: list[ConstraintItem] = Field(default_factory=list)
task_typing: TaskTyping
execution_mode: ExecutionMode
result_typing: ResultTyping
class ErrorInfo(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -136,6 +26,14 @@ class ErrorInfo(BaseModel):
details: dict[str, Any] | None = None
class RouterAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
objective: str
context_summary: str = ""
requires_tool_evidence: bool = False
class ToolAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -143,8 +41,9 @@ class ToolAgentOutput(BaseModel):
tool_call_id: str
tool_call_args: dict[str, Any] | None = None
status: ToolStatus
result: str
result: Any
error: ErrorInfo | None = None
ui_hints: dict[str, Any] | None = None
class WorkerAgentOutputLite(BaseModel):
@@ -152,26 +51,12 @@ class WorkerAgentOutputLite(BaseModel):
status: RunStatus = RunStatus.SUCCESS
answer: str
key_points: list[str] = Field(default_factory=list)
result_type: ResultType = ResultType.UNKNOWN
suggested_actions: list[str] = Field(default_factory=list)
error: ErrorInfo | None = None
class WorkerAgentOutputRich(WorkerAgentOutputLite):
ui_hints: UiHintsPayload | None = None
class AgentOutput(WorkerAgentOutputRich):
class AgentOutput(WorkerAgentOutputLite):
model_config = ConfigDict(extra="forbid")
WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich
def resolve_worker_output_model(
execution_mode: ExecutionMode,
) -> type[WorkerAgentOutputLite]:
if execution_mode == ExecutionMode.ONESTEP:
return WorkerAgentOutputLite
return WorkerAgentOutputRich
WorkerAgentOutput = WorkerAgentOutputLite
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, ConfigDict
class SkillName(str, Enum):
CALENDAR = "calendar"
CONTACTS = "contacts"
MEMORY = "memory"
class EnabledSkillConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
name: SkillName
def dedupe_enabled_skills(skills: list[EnabledSkillConfig]) -> list[EnabledSkillConfig]:
deduped: list[EnabledSkillConfig] = []
seen: set[SkillName] = set()
for skill in skills:
if skill.name in seen:
continue
deduped.append(skill)
seen.add(skill.name)
return deduped
def enabled_skill_names(skills: list[EnabledSkillConfig]) -> list[str]:
return [skill.name.value for skill in dedupe_enabled_skills(skills)]
+11 -11
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from enum import Enum
from core.agentscope.tools.tool_config import AgentTool, parse_agent_tool
from schemas.agent.skill_config import SkillName
from pydantic import BaseModel, Field, field_validator
@@ -28,24 +28,24 @@ class SystemAgentLLMConfig(BaseModel):
context_messages: ContextMessagesConfig = Field(
default_factory=ContextMessagesConfig
)
enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32)
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
@field_validator("enabled_tools", mode="before")
@field_validator("enabled_skills", mode="before")
@classmethod
def _normalize_enabled_tools(cls, value: object) -> list[AgentTool]:
def _normalize_enabled_skills(cls, value: object) -> list[SkillName]:
if value is None:
return []
if not isinstance(value, list):
raise ValueError("enabled_tools must be a list")
normalized: list[AgentTool] = []
raise ValueError("enabled_skills must be a list")
normalized: list[SkillName] = []
for item in value:
if isinstance(item, AgentTool):
tool = item
if isinstance(item, SkillName):
skill = item
else:
raw_item = str(item or "").strip()
if not raw_item:
continue
tool = parse_agent_tool(raw_item)
if tool not in normalized:
normalized.append(tool)
skill = SkillName(raw_item)
if skill not in normalized:
normalized.append(skill)
return normalized
+3 -3
View File
@@ -5,8 +5,8 @@ from enum import Enum
from typing import Protocol
from uuid import UUID
from core.agentscope.tools.tool_config import AgentTool
from pydantic import BaseModel, ConfigDict, Field, model_validator
from schemas.agent.skill_config import SkillName
from schemas.enums import AutomationJobStatus, ScheduleType
@@ -73,14 +73,14 @@ class ScheduleConfig(BaseModel):
class RuntimeConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32)
enabled_skills: list[SkillName] = Field(default_factory=list, max_length=32)
context: MessageContextConfig = Field(default_factory=MessageContextConfig)
class AutomationJobConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled_tools: list[AgentTool] | None = Field(default=None, max_length=32)
enabled_skills: list[SkillName] | 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
+1 -5
View File
@@ -10,9 +10,6 @@ from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.events import RedisStreamBus
from core.agentscope.tools.tool_result_storage import (
create_tool_result_storage,
)
from core.config.settings import config
from core.db import get_db
from services.base.redis import get_or_init_redis_client
@@ -141,9 +138,8 @@ class RedisEventStream:
def get_agent_service(session: AsyncSession = Depends(get_db)) -> AgentService:
tool_result_storage = create_tool_result_storage()
return AgentService(
repository=AgentRepository(session, tool_result_storage=tool_result_storage),
repository=AgentRepository(session),
queue=TaskiqQueueClient(),
stream=RedisEventStream(),
attachment_storage=supabase_service,
+1 -14
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from typing import Protocol
from uuid import UUID, uuid4
from sqlalchemy import Select, select
@@ -19,21 +18,9 @@ from schemas.domain.chat_message import (
)
class ToolResultPayloadStorage(Protocol):
async def read_json(
self, *, bucket: str, path: str
) -> dict[str, object] | None: ...
class AgentRepository:
def __init__(
self,
session: AsyncSession,
*,
tool_result_storage: ToolResultPayloadStorage | None = None,
) -> None:
def __init__(self, session: AsyncSession) -> None:
self._session: AsyncSession = session
self._tool_result_storage: ToolResultPayloadStorage | None = tool_result_storage
async def get_session_owner(self, *, session_id: str) -> str:
try:
+1 -1
View File
@@ -169,7 +169,7 @@ class HistoryMessage(BaseModel):
)
ui_schema: UiSchemaRenderer | None = Field(
default=None,
description="Compiled UI schema from worker ui_hints for frontend rendering",
description="UI schema payload when available in message data (assistant text does not generate ui_schema)",
)
timestamp: str = Field(description="Message creation timestamp in ISO-8601 format")
+5 -6
View File
@@ -9,7 +9,6 @@ from pathlib import Path
import yaml
from pydantic import ValidationError
from core.agentscope.tools.tool_config import AgentTool
from schemas.agent.system_agent import SystemAgentLLMConfig
from schemas.domain.automation import (
ContextSource,
@@ -67,7 +66,7 @@ def build_runtime_config_from_system_agents(
chat 模式使用:
- router.context_messages 配置 context
- worker.enabled_tools 配置 tools
- worker.enabled_skills 配置 skills
"""
raw = _load_system_agents_yaml(yaml_path)
agents_list = raw.get("agents", [])
@@ -94,11 +93,11 @@ def build_runtime_config_from_system_agents(
router_config.context_messages.model_dump() if router_config else None
)
enabled_tools: list[AgentTool] = []
if worker_config and worker_config.enabled_tools:
enabled_tools = list(worker_config.enabled_tools)
enabled_skills = []
if worker_config and worker_config.enabled_skills:
enabled_skills = list(worker_config.enabled_skills)
return RuntimeConfig(
enabled_tools=enabled_tools,
enabled_skills=enabled_skills,
context=context_cfg,
)
+1 -48
View File
@@ -7,7 +7,6 @@
from collections.abc import Callable
from typing import Any
from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints
from schemas.domain.chat_message import (
AgentChatMessage,
AgentChatMessageMetadata,
@@ -29,21 +28,16 @@ def convert_message_to_history(
转换规则:
- role=user: 读取 metadata.user_message_attachments,转换为 attachments[]
- role=assistant: 读取 metadata.agent_output.ui_hints,编译成 ui_schema
- role=assistant: 仅返回文本内容,不生成 ui_schema(UI 由 tool 结果单独承载)
"""
role = message.role
content = message.content
metadata = message.metadata
attachments: list[dict[str, str]] = []
ui_schema: dict[str, Any] | None = None
if role == "user":
attachments = _convert_user_attachments(metadata, get_signed_url_fn)
elif role == "assistant":
ui_schema = _compile_worker_ui_hints(metadata)
result: dict[str, Any] = {
"id": str(message.id),
"seq": message.seq,
@@ -55,9 +49,6 @@ def convert_message_to_history(
if attachments:
result["attachments"] = attachments
if ui_schema:
result["ui_schema"] = ui_schema
return result
@@ -93,44 +84,6 @@ def _convert_user_attachments(
return signed_attachments
def _compile_worker_ui_hints(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> dict[str, Any] | None:
"""编译 assistant 消息的 agent ui_hints"""
if not metadata:
return None
if isinstance(metadata, AgentChatMessageMetadata):
agent_output = metadata.agent_output
else:
agent_output_data = metadata.get("agent_output")
if not agent_output_data:
return None
if isinstance(agent_output_data, dict):
raw_ui_schema = agent_output_data.get("ui_schema")
if isinstance(raw_ui_schema, dict):
return raw_ui_schema
from schemas.agent.runtime_models import AgentOutput
try:
agent_output = AgentOutput.model_validate(agent_output_data)
except Exception:
return None
if not agent_output:
return None
ui_hints = agent_output.ui_hints
if not ui_hints:
return None
try:
compiled = compile_ui_hints(ui_hints)
return compiled
except Exception:
return None
def mime_to_suffix(mime_type: str) -> str:
mapping = {
"image/png": "png",
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import asyncio
import time
from typing import Any, cast
from uuid import UUID
import jwt
from core.config.settings import config
from core.http.errors import ApiProblemError
from core.logging import get_logger
from services.base.supabase import supabase_service
from v1.auth.schemas import AuthUser, PhoneSessionCreateRequest, SessionResponse
logger = get_logger("v1.auth.dev_phone_session")
def _auth_error(*, status_code: int, code: str, detail: str) -> ApiProblemError:
return ApiProblemError(status_code=status_code, code=code, detail=detail)
async def create_dev_phone_session(
*,
request: PhoneSessionCreateRequest,
) -> SessionResponse:
user_id = await _find_user_id_by_phone(request.phone)
if user_id is None:
raise _auth_error(
status_code=401,
code="AUTH_USER_NOT_FOUND",
detail="User not found",
)
token = _sign_access_token(sub=str(user_id))
return SessionResponse(
access_token=token,
refresh_token="dev-refresh-token",
expires_in=3600,
token_type="bearer",
user=AuthUser(id=str(user_id), phone=request.phone),
)
async def _find_user_id_by_phone(phone: str) -> UUID | None:
admin_client = supabase_service.get_admin_client()
users = await asyncio.to_thread(
_list_users_with_phone, admin_client, phone
)
if not users:
return None
raw_id = str(getattr(users[0], "id", ""))
return UUID(raw_id) if raw_id else None
def _list_users_with_phone(admin_client: Any, phone: str) -> list[Any]:
page = 1
while page <= 100:
response = admin_client.auth.admin.list_users(page=page, per_page=100)
batch = (
list(response)
if isinstance(response, list)
else list(getattr(response, "users", []))
)
matched = [
u
for u in batch
if _normalize_phone(getattr(u, "phone", "")) == _normalize_phone(phone)
]
if matched:
return matched
if len(batch) < 100:
break
page += 1
return []
def _normalize_phone(raw: object) -> str:
s = str(raw).strip()
for ch in (" ", "-", "(", ")"):
s = s.replace(ch, "")
if s.startswith("+"):
return s
if s.startswith("00") and len(s) > 2:
return f"+{s[2:]}"
if s.isdigit():
return f"+{s}"
return s
def _sign_access_token(*, sub: str) -> str:
secret = config.supabase.jwt_secret
if secret is None:
raise _auth_error(
status_code=500,
code="AUTH_CONFIG_ERROR",
detail="JWT secret not configured",
)
now = int(time.time())
payload = {
"sub": sub,
"aud": "authenticated",
"iss": config.supabase.jwt_issuer,
"exp": now + 3600,
"iat": now,
}
return cast(
str,
jwt.encode(payload, secret.get_secret_value(), algorithm=config.supabase.jwt_algorithm),
)
+5
View File
@@ -8,9 +8,11 @@ from pydantic import ValidationError
from supabase import AuthError
from core.config.settings import config
from core.http.errors import ApiProblemError
from core.logging import get_logger
from services.base.supabase import supabase_service
from v1.auth.dev_phone_session import create_dev_phone_session
from v1.auth.schemas import (
AuthUser,
OtpSendRequest,
@@ -75,6 +77,9 @@ class SupabaseAuthGateway(AuthServiceGateway):
async def create_phone_session(
self, request: PhoneSessionCreateRequest
) -> SessionResponse:
if config.runtime.environment == "dev":
return await create_dev_phone_session(request=request)
client = self._get_client()
payload: dict[str, Any] = {
"type": "sms",
+14 -13
View File
@@ -47,19 +47,20 @@ async def create_phone_session(
request: Request,
service: AuthService = Depends(get_auth_service),
) -> SessionResponse:
client_ip = _client_ip(request)
await enforce_rate_limit(
scope="phone_session_phone",
identifier=payload.phone,
limit=6,
window_seconds=300,
)
await enforce_rate_limit(
scope="phone_session_ip",
identifier=client_ip,
limit=20,
window_seconds=300,
)
if config.runtime.environment != "dev":
client_ip = _client_ip(request)
await enforce_rate_limit(
scope="phone_session_phone",
identifier=payload.phone,
limit=6,
window_seconds=300,
)
await enforce_rate_limit(
scope="phone_session_ip",
identifier=client_ip,
limit=20,
window_seconds=300,
)
return await service.create_phone_session(payload)
+1 -1
View File
@@ -165,7 +165,7 @@ class AutomationJobsService:
run_id=run_id,
input_text=input_text,
runtime_config=RuntimeConfig(
enabled_tools=job.config.enabled_tools or [],
enabled_skills=job.config.enabled_skills or [],
context=job.config.context or MessageContextConfig(),
),
)