refactor: unify skills+cli runtime and streamline ag-ui flow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user