refactor: 重构 Tool Result 契约,移除 ui_hints 统一使用 result 字段
- ToolAgentOutput 移除 result_summary 和 ui_hints,统一使用 result 字段 - 日历/用户查找工具移除 ui_hints 输出,改为机器可读的结构化结果 - Agent History 移除 tool 消息的 ui_hints 处理逻辑 - App 版本检查改为 manifest.json 方式,支持多渠道发布 - 更新 settings 配置和测试用例适配新结构
This commit is contained in:
@@ -246,6 +246,17 @@ class AgentType(str, Enum):
|
||||
|
||||
Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance.
|
||||
|
||||
## Custom Tool Result Contract
|
||||
|
||||
Custom tool `ToolAgentOutput` MUST follow these rules:
|
||||
|
||||
- Use field name `result` only. Do not introduce or keep `result_summary` compatibility aliases.
|
||||
- `result` is for downstream agent reasoning and tool chaining, not for end-user presentation.
|
||||
- Prefer compact structural facts over prose: include identifiers and execution-critical facts (`id`, `status`, `count`, `page`, operation outcome, missing required args).
|
||||
- For list/read tools, include multiple candidate records when needed (at least top matches) with stable identifiers.
|
||||
- For write tools, always include affected resource identifiers in `result`.
|
||||
- Keep `result` concise, deterministic, and machine-oriented; avoid decorative wording and UI-style formatting.
|
||||
|
||||
## Multi-Agent Orchestration (AgentScope Framework)
|
||||
|
||||
Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentscope-skill`** for framework reference and implementation guidance.
|
||||
@@ -264,3 +275,12 @@ Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentsco
|
||||
- **Pipelines**: Ordered orchestration flow between agents
|
||||
- **Tools**: Capabilities available to agents
|
||||
- **Flows**: Workflow orchestration and state management
|
||||
|
||||
## Testing
|
||||
|
||||
### Real Database Tests
|
||||
|
||||
Tests requiring real Supabase operations MUST use environment variables:
|
||||
- Define `TestSettings` in `settings.py` with nested configuration
|
||||
- Access via `settings.test.email` / `settings.test.password`
|
||||
- NEVER hardcode credentials in code
|
||||
|
||||
@@ -54,10 +54,7 @@ def _is_agui_event(event: dict[str, Any]) -> bool:
|
||||
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 in {
|
||||
EventType.TEXT_MESSAGE_END.value,
|
||||
EventType.TOOL_CALL_RESULT.value,
|
||||
}:
|
||||
if event_type == EventType.TEXT_MESSAGE_END.value:
|
||||
ui_hints = payload.get("ui_hints")
|
||||
if ui_hints is not None:
|
||||
try:
|
||||
@@ -67,7 +64,6 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
payload.pop("ui_hints", None)
|
||||
if event_type == EventType.TEXT_MESSAGE_END.value:
|
||||
for key in (
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
@@ -76,6 +72,9 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
|
||||
"model",
|
||||
):
|
||||
payload.pop(key, None)
|
||||
if event_type == EventType.TOOL_CALL_RESULT.value:
|
||||
payload.pop("ui_hints", None)
|
||||
payload.pop("ui_schema", None)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -130,10 +129,13 @@ def _build_text_end(event: dict[str, Any]) -> TextMessageEndEvent:
|
||||
|
||||
def _build_tool_result(event: dict[str, Any]) -> ToolCallResultEvent:
|
||||
data = event.get("data", {})
|
||||
content = data.get("result")
|
||||
if not isinstance(content, str):
|
||||
content = data.get("toolAgentOutput", "")
|
||||
return ToolCallResultEvent(
|
||||
message_id=data.get("messageId", ""),
|
||||
tool_call_id=data.get("toolCallId", ""),
|
||||
content=data.get("toolAgentOutput", ""),
|
||||
content=content,
|
||||
role="tool",
|
||||
)
|
||||
|
||||
@@ -191,7 +193,7 @@ 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"}
|
||||
reserved = {"type", "threadId", "runId", "ui_hints", "ui_schema"}
|
||||
tool_result_payload.update({k: v for k, v in data.items() if k not in reserved})
|
||||
return tool_result_payload
|
||||
|
||||
|
||||
@@ -213,9 +213,8 @@ class SqlAlchemyEventStore:
|
||||
"tool_call_id": self._event_value(event, "tool_call_id"),
|
||||
"tool_call_args": self._event_value(event, "tool_call_args"),
|
||||
"status": self._event_value(event, "status"),
|
||||
"result_summary": self._event_value(event, "result_summary"),
|
||||
"result": self._event_value(event, "result"),
|
||||
"error": self._event_value(event, "error"),
|
||||
"ui_hints": self._event_value(event, "ui_hints"),
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -231,7 +230,7 @@ class SqlAlchemyEventStore:
|
||||
)
|
||||
return
|
||||
|
||||
content = tool_output.result_summary
|
||||
content = tool_output.result
|
||||
|
||||
locked_session = await session_repo.lock_session_for_update(
|
||||
session_id=session_id
|
||||
|
||||
@@ -112,13 +112,8 @@ class PipelineStageEmitter:
|
||||
"tool_call_id": tool_output.tool_call_id,
|
||||
"tool_call_args": tool_output.tool_call_args,
|
||||
"status": tool_output.status.value,
|
||||
"result_summary": tool_output.result_summary,
|
||||
"result": tool_output.result,
|
||||
}
|
||||
ui_hints = tool_output.model_dump(mode="json", exclude_none=True).get(
|
||||
"ui_hints"
|
||||
)
|
||||
if ui_hints is not None:
|
||||
payload["ui_hints"] = ui_hints
|
||||
if tool_output.error:
|
||||
payload["error"] = tool_output.error.model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -16,13 +16,9 @@ from core.agentscope.tools.utils.calendar_domain import (
|
||||
)
|
||||
from core.agentscope.tools.utils.calendar_ui import (
|
||||
calendar_error_output,
|
||||
calendar_read_hints,
|
||||
calendar_share_hints,
|
||||
calendar_write_hints,
|
||||
dump_tool_output,
|
||||
)
|
||||
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
|
||||
from schemas.agent.ui_hints import UiHintListItem, UiHintStatus
|
||||
from v1.schedule_items.schemas import (
|
||||
ScheduleItemCreateRequest,
|
||||
ScheduleItemShareRequest,
|
||||
@@ -73,6 +69,18 @@ def _validate_runtime_context(
|
||||
return None
|
||||
|
||||
|
||||
def _format_event_brief(event_items: list[dict[str, Any]], limit: int = 3) -> str:
|
||||
briefs: list[str] = []
|
||||
for item in event_items[:limit]:
|
||||
event_id = str(item.get("id") or "")
|
||||
title = str(item.get("title") or "")
|
||||
start_at = str(item.get("startAt") or "")
|
||||
if not event_id:
|
||||
continue
|
||||
briefs.append(f"{{id={event_id},title={title},startAt={start_at}}}")
|
||||
return ",".join(briefs)
|
||||
|
||||
|
||||
async def calendar_read(
|
||||
query: Annotated[
|
||||
str | None,
|
||||
@@ -114,24 +122,28 @@ async def calendar_read(
|
||||
service = create_schedule_service(
|
||||
cast(AsyncSession, session), cast(UUID, owner_id)
|
||||
)
|
||||
items, total = await service.list_paginated(page=page, page_size=page_size)
|
||||
total_pages = max(1, (total + page_size - 1) // page_size) if total else 0
|
||||
|
||||
items, total = await service.list_paginated(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
)
|
||||
total_pages = (total + page_size - 1) // page_size if total else 0
|
||||
event_items = [schedule_event_to_dict(item) for item in items]
|
||||
query_value = (query or "").strip() or "*"
|
||||
event_brief = _format_event_brief(event_items)
|
||||
summary = (
|
||||
f"status=success query={query_value} total={total} page={page}/"
|
||||
f"{total_pages or 1} returned={len(event_items)}"
|
||||
)
|
||||
if event_brief:
|
||||
summary = f"{summary} items=[{event_brief}]"
|
||||
return dump_tool_output(
|
||||
ToolAgentOutput(
|
||||
tool_name=tool_name,
|
||||
tool_call_id=f"{tool_name}-call",
|
||||
tool_call_args=tool_call_args,
|
||||
status=ToolStatus.SUCCESS,
|
||||
result_summary=f"已获取日程列表,共 {total} 条",
|
||||
ui_hints=calendar_read_hints(
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
events=event_items,
|
||||
),
|
||||
result=summary,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -297,6 +309,7 @@ async def calendar_write(
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
success_event_ids: list[str] = []
|
||||
result_items: list[dict[str, Any]] = []
|
||||
|
||||
for idx, operation in enumerate(operations):
|
||||
@@ -353,6 +366,7 @@ async def calendar_write(
|
||||
"message": f"日程「{created.title}」已创建",
|
||||
}
|
||||
)
|
||||
success_event_ids.append(str(created.id))
|
||||
continue
|
||||
|
||||
if operation == "update":
|
||||
@@ -397,6 +411,7 @@ async def calendar_write(
|
||||
"message": f"日程「{updated.title}」已更新",
|
||||
}
|
||||
)
|
||||
success_event_ids.append(str(updated.id))
|
||||
continue
|
||||
|
||||
if operation == "delete":
|
||||
@@ -413,6 +428,7 @@ async def calendar_write(
|
||||
"message": f"日程 {event_id} 已删除",
|
||||
}
|
||||
)
|
||||
success_event_ids.append(event_id)
|
||||
continue
|
||||
except Exception as exc:
|
||||
code, message, _ = map_calendar_exception(exc)
|
||||
@@ -430,16 +446,22 @@ async def calendar_write(
|
||||
|
||||
if failed_count == 0:
|
||||
final_status = ToolStatus.SUCCESS
|
||||
ui_status = UiHintStatus.SUCCESS
|
||||
summary = f"日程批量操作完成,共 {batch_size} 条,成功 {success_count} 条"
|
||||
summary = (
|
||||
f"status=success batch={batch_size} success={success_count} "
|
||||
f"failed={failed_count} ids=[{','.join(success_event_ids)}]"
|
||||
)
|
||||
elif success_count == 0:
|
||||
final_status = ToolStatus.FAILURE
|
||||
ui_status = UiHintStatus.ERROR
|
||||
summary = f"日程批量操作失败,共 {batch_size} 条,失败 {failed_count} 条"
|
||||
summary = (
|
||||
f"status=failure batch={batch_size} success={success_count} "
|
||||
f"failed={failed_count}"
|
||||
)
|
||||
else:
|
||||
final_status = ToolStatus.PARTIAL
|
||||
ui_status = UiHintStatus.WARNING
|
||||
summary = f"日程批量操作部分成功,共 {batch_size} 条,成功 {success_count} 条,失败 {failed_count} 条"
|
||||
summary = (
|
||||
f"status=partial batch={batch_size} success={success_count} "
|
||||
f"failed={failed_count} ids=[{','.join(success_event_ids)}]"
|
||||
)
|
||||
|
||||
error_info: ErrorInfo | None = None
|
||||
if final_status == ToolStatus.FAILURE:
|
||||
@@ -459,30 +481,10 @@ async def calendar_write(
|
||||
retryable=False,
|
||||
details={"results": result_items},
|
||||
)
|
||||
|
||||
result_list_items = [
|
||||
UiHintListItem(
|
||||
id=(
|
||||
str(item.get("eventId"))
|
||||
if isinstance(item, dict) and item.get("eventId") is not None
|
||||
else None
|
||||
),
|
||||
title=(
|
||||
f"#{int(item.get('index', 0)) + 1} {str(item.get('operation', 'unknown'))}"
|
||||
if isinstance(item, dict)
|
||||
else "unknown"
|
||||
),
|
||||
subtitle=(
|
||||
"成功"
|
||||
if isinstance(item, dict) and item.get("status") == "success"
|
||||
else "失败"
|
||||
),
|
||||
description=(
|
||||
str(item.get("message") or "") if isinstance(item, dict) else ""
|
||||
),
|
||||
summary = (
|
||||
f"{summary} first_error_code={error_info.code} "
|
||||
f"first_error_message={error_info.message}"
|
||||
)
|
||||
for item in result_items
|
||||
]
|
||||
|
||||
return dump_tool_output(
|
||||
ToolAgentOutput(
|
||||
@@ -490,24 +492,8 @@ async def calendar_write(
|
||||
tool_call_id=f"{tool_name}-call",
|
||||
tool_call_args=tool_call_args,
|
||||
status=final_status,
|
||||
result_summary=summary,
|
||||
result=summary,
|
||||
error=error_info,
|
||||
ui_hints=calendar_write_hints(
|
||||
operation="batch",
|
||||
message=summary,
|
||||
event=None,
|
||||
event_id=None,
|
||||
status=ui_status,
|
||||
).model_copy(
|
||||
update={
|
||||
"list_items": result_list_items,
|
||||
"meta": {
|
||||
"total": batch_size,
|
||||
"success": success_count,
|
||||
"failed": failed_count,
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -611,19 +597,14 @@ async def calendar_share(
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
summary = f"日程已分享,已邀请 {len(invited)} 人"
|
||||
summary = f"status=success event_id={event_id} invited_count={len(invited)}"
|
||||
return dump_tool_output(
|
||||
ToolAgentOutput(
|
||||
tool_name=tool_name,
|
||||
tool_call_id=f"{tool_name}-call",
|
||||
tool_call_args=tool_call_args,
|
||||
status=ToolStatus.SUCCESS,
|
||||
result_summary=summary,
|
||||
ui_hints=calendar_share_hints(
|
||||
event_id=event_id,
|
||||
invited=invited,
|
||||
permission={"per_user": True},
|
||||
),
|
||||
result=summary,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -17,15 +17,6 @@ from core.agentscope.tools.utils.tool_response_builder import (
|
||||
)
|
||||
from models.profile import Profile
|
||||
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
|
||||
from schemas.agent.ui_hints import (
|
||||
UiHintAction,
|
||||
UiHintActionCopy,
|
||||
UiHintActionStyle,
|
||||
UiHintIntent,
|
||||
UiHintKvItem,
|
||||
UiHintStatus,
|
||||
UiHintsPayload,
|
||||
)
|
||||
from v1.auth.gateway import SupabaseAuthGateway
|
||||
|
||||
|
||||
@@ -47,51 +38,10 @@ def _lookup_error_output(
|
||||
message=message,
|
||||
retryable=retryable,
|
||||
)
|
||||
output = output.model_copy(
|
||||
update={
|
||||
"tool_call_args": tool_call_args,
|
||||
"ui_hints": UiHintsPayload(
|
||||
intent=UiHintIntent.STATUS,
|
||||
status=UiHintStatus.ERROR,
|
||||
title="用户查找失败",
|
||||
body=message,
|
||||
),
|
||||
}
|
||||
)
|
||||
output = output.model_copy(update={"tool_call_args": tool_call_args})
|
||||
return _dump_tool_output(output)
|
||||
|
||||
|
||||
def _lookup_success_hints(resolved: dict[str, Any]) -> UiHintsPayload:
|
||||
user_id = str(resolved.get("userId") or "")
|
||||
email = str(resolved.get("email") or "")
|
||||
username = str(resolved.get("username") or "")
|
||||
matched_by = str(resolved.get("matchedBy") or "")
|
||||
return UiHintsPayload(
|
||||
intent=UiHintIntent.DATA,
|
||||
status=UiHintStatus.SUCCESS,
|
||||
title="用户信息",
|
||||
description=f"匹配方式: {matched_by}",
|
||||
items=[
|
||||
UiHintKvItem(key="user_id", label="用户ID", value=user_id, copyable=True),
|
||||
UiHintKvItem(key="email", label="邮箱", value=email, copyable=True),
|
||||
UiHintKvItem(key="username", label="用户名", value=username or "-"),
|
||||
UiHintKvItem(key="matched_by", label="匹配方式", value=matched_by),
|
||||
],
|
||||
actions=[
|
||||
UiHintAction(
|
||||
label="复制用户ID",
|
||||
style=UiHintActionStyle.SECONDARY,
|
||||
action=UiHintActionCopy(
|
||||
type="copy",
|
||||
content=user_id,
|
||||
successMessage="用户ID已复制",
|
||||
),
|
||||
disabled=not bool(user_id),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_identity(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
@@ -189,15 +139,19 @@ async def user_lookup(
|
||||
|
||||
username = str(resolved.get("username") or "")
|
||||
email = str(resolved.get("email") or "")
|
||||
summary = f"已找到用户: {username or email}"
|
||||
user_id = str(resolved.get("userId") or "")
|
||||
matched_by = str(resolved.get("matchedBy") or "")
|
||||
summary = (
|
||||
f"status=success matched_by={matched_by} user_id={user_id} "
|
||||
f"username={username} has_email={str(bool(email)).lower()}"
|
||||
)
|
||||
return _dump_tool_output(
|
||||
ToolAgentOutput(
|
||||
tool_name="user_lookup",
|
||||
tool_call_id="user_lookup-call",
|
||||
tool_call_args=tool_call_args,
|
||||
status=ToolStatus.SUCCESS,
|
||||
result_summary=summary,
|
||||
ui_hints=_lookup_success_hints(resolved),
|
||||
result=summary,
|
||||
)
|
||||
)
|
||||
except HTTPException as exc:
|
||||
|
||||
@@ -8,16 +8,6 @@ from core.agentscope.tools.utils.tool_response_builder import (
|
||||
build_tool_response,
|
||||
)
|
||||
from schemas.agent.runtime_models import ToolAgentOutput
|
||||
from schemas.agent.ui_hints import (
|
||||
UiHintAction,
|
||||
UiHintActionNavigation,
|
||||
UiHintActionStyle,
|
||||
UiHintIntent,
|
||||
UiHintKvItem,
|
||||
UiHintListItem,
|
||||
UiHintStatus,
|
||||
UiHintsPayload,
|
||||
)
|
||||
|
||||
|
||||
def dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
|
||||
@@ -32,12 +22,6 @@ def calendar_error_output(
|
||||
message: str,
|
||||
retryable: bool,
|
||||
) -> ToolResponse:
|
||||
ui_hints = UiHintsPayload(
|
||||
intent=UiHintIntent.STATUS,
|
||||
status=UiHintStatus.ERROR,
|
||||
title="日历操作失败",
|
||||
body=message,
|
||||
)
|
||||
output = build_error_output(
|
||||
tool_name=tool_name,
|
||||
tool_call_id=f"{tool_name}-call",
|
||||
@@ -45,120 +29,5 @@ def calendar_error_output(
|
||||
message=message,
|
||||
retryable=retryable,
|
||||
)
|
||||
output = output.model_copy(
|
||||
update={"tool_call_args": tool_call_args, "ui_hints": ui_hints}
|
||||
)
|
||||
output = output.model_copy(update={"tool_call_args": tool_call_args})
|
||||
return dump_tool_output(output)
|
||||
|
||||
|
||||
def calendar_read_hints(
|
||||
*,
|
||||
total: int,
|
||||
page: int,
|
||||
page_size: int,
|
||||
total_pages: int,
|
||||
events: list[dict[str, Any]],
|
||||
) -> UiHintsPayload:
|
||||
event_items = [
|
||||
UiHintListItem(
|
||||
id=event.get("id"),
|
||||
title=str(event.get("title") or "未命名日程"),
|
||||
subtitle=str(event.get("startAt") or ""),
|
||||
description=str(event.get("location") or "") or None,
|
||||
)
|
||||
for event in events
|
||||
]
|
||||
return UiHintsPayload(
|
||||
intent=UiHintIntent.LIST,
|
||||
status=UiHintStatus.SUCCESS,
|
||||
title="日程列表",
|
||||
description=f"共 {total} 个日程",
|
||||
items=[
|
||||
UiHintKvItem(key="total", label="总数", value=total),
|
||||
UiHintKvItem(key="page", label="当前页", value=page),
|
||||
UiHintKvItem(key="page_size", label="每页", value=page_size),
|
||||
UiHintKvItem(key="total_pages", label="总页数", value=total_pages),
|
||||
],
|
||||
listItems=event_items,
|
||||
actions=[
|
||||
UiHintAction(
|
||||
label="打开日历",
|
||||
style=UiHintActionStyle.PRIMARY,
|
||||
action=UiHintActionNavigation(type="navigation", path="/calendar"),
|
||||
)
|
||||
],
|
||||
meta={"total": total, "page": page, "page_size": page_size},
|
||||
)
|
||||
|
||||
|
||||
def calendar_write_hints(
|
||||
*,
|
||||
operation: str,
|
||||
message: str,
|
||||
event: dict[str, Any] | None,
|
||||
event_id: str | None,
|
||||
status: UiHintStatus = UiHintStatus.SUCCESS,
|
||||
) -> UiHintsPayload:
|
||||
kv_items: list[UiHintKvItem] = []
|
||||
|
||||
if event:
|
||||
kv_items = [
|
||||
UiHintKvItem(
|
||||
key="event_id",
|
||||
label="日程ID",
|
||||
value=str(event.get("id") or ""),
|
||||
copyable=True,
|
||||
),
|
||||
UiHintKvItem(
|
||||
key="title",
|
||||
label="标题",
|
||||
value=str(event.get("title") or ""),
|
||||
copyable=True,
|
||||
),
|
||||
UiHintKvItem(
|
||||
key="start_at",
|
||||
label="开始时间",
|
||||
value=str(event.get("startAt") or ""),
|
||||
copyable=True,
|
||||
),
|
||||
]
|
||||
elif event_id:
|
||||
message = f"目标日程 ID: {event_id}\n{message}"
|
||||
|
||||
return UiHintsPayload(
|
||||
intent=UiHintIntent.STATUS,
|
||||
status=status,
|
||||
title="日历操作完成",
|
||||
body=message,
|
||||
items=kv_items,
|
||||
actions=[
|
||||
UiHintAction(
|
||||
label="查看日历",
|
||||
style=UiHintActionStyle.PRIMARY,
|
||||
action=UiHintActionNavigation(type="navigation", path="/calendar"),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def calendar_share_hints(
|
||||
*,
|
||||
event_id: str,
|
||||
invited: list[str],
|
||||
permission: dict[str, Any],
|
||||
) -> UiHintsPayload:
|
||||
permission_text = (
|
||||
", ".join([k for k, v in permission.items() if v is True]) or "按邀请人单独设置"
|
||||
)
|
||||
|
||||
return UiHintsPayload(
|
||||
intent=UiHintIntent.STATUS,
|
||||
status=UiHintStatus.SUCCESS,
|
||||
title="日程已分享",
|
||||
description=f"已邀请 {len(invited)} 人",
|
||||
items=[
|
||||
UiHintKvItem(key="event_id", label="日程ID", value=event_id, copyable=True),
|
||||
UiHintKvItem(key="permission", label="权限", value=permission_text),
|
||||
],
|
||||
listItems=[UiHintListItem(title=email) for email in invited] if invited else [],
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ def build_error_output(
|
||||
tool_name=tool_name,
|
||||
tool_call_id=tool_call_id,
|
||||
status=ToolStatus.FAILURE,
|
||||
result_summary=message,
|
||||
result=f"status=failure code={code} message={message}",
|
||||
error=ErrorInfo(
|
||||
code=code,
|
||||
message=message,
|
||||
|
||||
@@ -199,23 +199,39 @@ class DatabaseSettings(BaseModel):
|
||||
|
||||
|
||||
class AppVersionSettings(BaseModel):
|
||||
releases_dir: str = Field(
|
||||
manifest_path: str = Field(
|
||||
default="deploy/static/releases/manifest.json",
|
||||
description="发布清单文件路径,相对于项目根目录",
|
||||
)
|
||||
release_path_prefix: str = Field(
|
||||
default="releases",
|
||||
description="安装包目录,相对于项目根目录",
|
||||
description="下载 URL 中文件目录前缀",
|
||||
)
|
||||
current_version: str = Field(
|
||||
default="0.1.0",
|
||||
description="当前版本号",
|
||||
)
|
||||
current_build: int = Field(
|
||||
default=1,
|
||||
description="当前构建号",
|
||||
)
|
||||
download_base_url: str = Field(
|
||||
default="",
|
||||
download_base_url: AnyHttpUrl | None = Field(
|
||||
default=None,
|
||||
description="下载链接基础域名,如 https://your-domain.com",
|
||||
)
|
||||
|
||||
@field_validator("download_base_url", mode="before")
|
||||
@classmethod
|
||||
def empty_download_base_url_to_none(cls, value: object) -> object:
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
@field_validator("manifest_path")
|
||||
@classmethod
|
||||
def validate_manifest_path(cls, value: str) -> str:
|
||||
normalized = Path(value)
|
||||
if normalized.is_absolute() or ".." in normalized.parts:
|
||||
raise ValueError("manifest_path must be a safe relative path")
|
||||
return value
|
||||
|
||||
|
||||
class TestSettings(BaseModel):
|
||||
email: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
def _resolve_env_file() -> str:
|
||||
current = Path(__file__).resolve()
|
||||
@@ -241,6 +257,7 @@ class Settings(BaseSettings):
|
||||
taskiq: TaskiqSettings = TaskiqSettings()
|
||||
database: DatabaseSettings = DatabaseSettings()
|
||||
app_version: AppVersionSettings = AppVersionSettings()
|
||||
test: TestSettings = Field(default_factory=TestSettings)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
|
||||
@@ -312,13 +312,12 @@ class ToolAgentOutput(BaseModel):
|
||||
description="Snapshot of tool call arguments for traceability and debugging.",
|
||||
)
|
||||
status: ToolStatus = Field(..., description="Tool execution status.")
|
||||
result_summary: str = Field(
|
||||
result: str = Field(
|
||||
...,
|
||||
description="Concise tool result summary with key facts and without verbose logs.",
|
||||
)
|
||||
ui_hints: UiHintsPayload | None = Field(
|
||||
default=None,
|
||||
description="Optional UI semantic hints translated into ui_schema by ui_compiler.",
|
||||
description=(
|
||||
"Compact machine-oriented tool result. Keep it short but include "
|
||||
"action-critical facts (ids/status/counts) for downstream agent steps."
|
||||
),
|
||||
)
|
||||
error: ErrorInfo | None = Field(
|
||||
default=None, description="Tool execution error details."
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from schemas.agent.ui_schema import UiSchemaRenderer
|
||||
@@ -51,7 +53,9 @@ class HistoryMessage(BaseModel):
|
||||
|
||||
id: str = Field(description="Message UUID")
|
||||
seq: int = Field(description="Message sequence number")
|
||||
role: str = Field(description="Message role: user | assistant | tool")
|
||||
role: Literal["user", "assistant"] = Field(
|
||||
description="Message role: user | assistant"
|
||||
)
|
||||
content: str = Field(description="Message text content")
|
||||
attachments: list[HistoryMessageAttachment] = Field(
|
||||
default_factory=list,
|
||||
@@ -59,7 +63,7 @@ class HistoryMessage(BaseModel):
|
||||
)
|
||||
ui_schema: UiSchemaRenderer | None = Field(
|
||||
default=None,
|
||||
description="Compiled UI schema from worker/tool ui_hints for frontend rendering",
|
||||
description="Compiled UI schema from worker ui_hints for frontend rendering",
|
||||
)
|
||||
timestamp: str = Field(description="Message creation timestamp in ISO-8601 format")
|
||||
|
||||
|
||||
@@ -449,6 +449,8 @@ class AgentService:
|
||||
)
|
||||
for msg_dict in raw_messages:
|
||||
msg = AgentChatMessage.model_validate(msg_dict)
|
||||
if msg.role == "tool":
|
||||
continue
|
||||
|
||||
signed_urls: dict[str, str] = {}
|
||||
attachments = extract_user_message_attachments(msg.metadata)
|
||||
|
||||
@@ -24,7 +24,6 @@ def convert_message_to_history(
|
||||
|
||||
转换规则:
|
||||
- role=user: 读取 metadata.user_message_attachments,转换为 attachments[]
|
||||
- role=tool: 读取 content 和 metadata.tool_agent_output.ui_hints,编译成 ui_schema
|
||||
- role=assistant: 读取 metadata.worker_agent_output.ui_hints,编译成 ui_schema
|
||||
"""
|
||||
role = message.role
|
||||
@@ -37,9 +36,6 @@ def convert_message_to_history(
|
||||
if role == "user":
|
||||
attachments = _convert_user_attachments(metadata, get_signed_url_fn)
|
||||
|
||||
elif role == "tool":
|
||||
ui_schema = _compile_tool_ui_hints(metadata)
|
||||
|
||||
elif role == "assistant":
|
||||
ui_schema = _compile_worker_ui_hints(metadata)
|
||||
|
||||
@@ -92,47 +88,6 @@ def _convert_user_attachments(
|
||||
return signed_attachments
|
||||
|
||||
|
||||
def _compile_tool_ui_hints(
|
||||
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""编译 tool 消息的 ui_hints"""
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
if isinstance(metadata, AgentChatMessageMetadata):
|
||||
tool_output = metadata.tool_agent_output
|
||||
else:
|
||||
tool_output_data = metadata.get("tool_agent_output")
|
||||
if not tool_output_data:
|
||||
return None
|
||||
if isinstance(tool_output_data, dict):
|
||||
raw_ui_schema = tool_output_data.get("ui_schema")
|
||||
if isinstance(raw_ui_schema, dict):
|
||||
return raw_ui_schema
|
||||
legacy_ui_schema = tool_output_data.get("uiSchema")
|
||||
if isinstance(legacy_ui_schema, dict):
|
||||
return legacy_ui_schema
|
||||
from schemas.agent.runtime_models import ToolAgentOutput
|
||||
|
||||
try:
|
||||
tool_output = ToolAgentOutput.model_validate(tool_output_data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not tool_output:
|
||||
return None
|
||||
|
||||
ui_hints = tool_output.ui_hints
|
||||
if not ui_hints:
|
||||
return None
|
||||
|
||||
try:
|
||||
compiled = compile_ui_hints(ui_hints)
|
||||
return compiled
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _compile_worker_ui_hints(
|
||||
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
|
||||
+109
-77
@@ -1,128 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from core.config.settings import config
|
||||
from core.config.settings import PROJECT_ROOT, config
|
||||
from core.logging import get_logger
|
||||
|
||||
|
||||
class AppVersionInfo(BaseModel):
|
||||
has_update: bool = Field(description="是否有新版本可用")
|
||||
latest_version: str = Field(description="最新版本号,如 0.1.0")
|
||||
latest_build: int = Field(description="最新构建号")
|
||||
min_required_version: str = Field(description="强制更新版本号")
|
||||
update_type: Literal["none", "optional", "required"] = Field(
|
||||
description="更新类型: none=无更新, optional=可选更新, required=必须更新"
|
||||
)
|
||||
latest_version_name: str = Field(description="最新展示版本号,如 0.1.1")
|
||||
latest_version_code: int = Field(description="最新构建号(versionCode/buildNumber)")
|
||||
min_supported_version_code: int = Field(description="最低支持版本构建号")
|
||||
download_url: str | None = Field(default=None, description="安装包下载链接")
|
||||
release_notes: str | None = Field(default=None, description="版本更新说明")
|
||||
file_name: str | None = Field(default=None, description="安装包文件名")
|
||||
file_size: int | None = Field(default=None, description="安装包大小(字节)")
|
||||
sha256: str | None = Field(default=None, description="安装包哈希")
|
||||
|
||||
|
||||
class ReleaseRecord(BaseModel):
|
||||
platform: Literal["android", "ios"]
|
||||
channel: str = Field(default="release", min_length=1, max_length=32)
|
||||
version_name: str = Field(min_length=1, max_length=32)
|
||||
version_code: int = Field(ge=1)
|
||||
min_supported_version_code: int = Field(ge=1)
|
||||
file_name: str = Field(min_length=1)
|
||||
release_notes: str | None = None
|
||||
file_size: int | None = Field(default=None, ge=1)
|
||||
sha256: str | None = Field(default=None, min_length=32, max_length=128)
|
||||
|
||||
|
||||
class ReleaseManifest(BaseModel):
|
||||
releases: list[ReleaseRecord] = Field(default_factory=list)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/app", tags=["app"])
|
||||
logger = get_logger("api.app.version")
|
||||
|
||||
|
||||
def _parse_version(filename: str) -> tuple[str, int, tuple[int, ...]] | None:
|
||||
pattern = r"app[-_]v?(\d+\.\d+\.\d+)\+(\d+)\.(?:apk|ipa)"
|
||||
match = re.search(pattern, filename, re.IGNORECASE)
|
||||
if match:
|
||||
version = match.group(1)
|
||||
build = int(match.group(2))
|
||||
version_tuple = tuple(int(x) for x in version.split("."))
|
||||
return (version, build, version_tuple)
|
||||
return None
|
||||
def _manifest_file_path() -> Path:
|
||||
return (PROJECT_ROOT / config.app_version.manifest_path).resolve()
|
||||
|
||||
|
||||
def _get_latest_release(
|
||||
platform: Literal["ios", "android"],
|
||||
) -> tuple[str, int, str] | None:
|
||||
releases_dir = config.app_version.releases_dir
|
||||
base_path = Path.cwd().parent / "deploy" / "static" / releases_dir
|
||||
def _load_manifest() -> ReleaseManifest:
|
||||
manifest_file = _manifest_file_path()
|
||||
if not manifest_file.is_file():
|
||||
return ReleaseManifest()
|
||||
|
||||
if not base_path.exists():
|
||||
return None
|
||||
try:
|
||||
raw = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||
return ReleaseManifest.model_validate(raw)
|
||||
except (json.JSONDecodeError, OSError, ValidationError):
|
||||
logger.warning("Invalid release manifest, fallback to empty manifest")
|
||||
return ReleaseManifest()
|
||||
|
||||
target_ext = "ipa" if platform == "ios" else "apk"
|
||||
candidates = []
|
||||
|
||||
MIN_APK_SIZE = 1024 * 1024 # 1MB
|
||||
MIN_IPA_SIZE = 1024 * 1024 # 1MB
|
||||
|
||||
for f in base_path.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
ext = f.suffix.lstrip(".").lower()
|
||||
if ext != target_ext:
|
||||
continue
|
||||
# 简单校验文件大小,排除伪装文件
|
||||
if f.stat().st_size < (MIN_APK_SIZE if ext == "apk" else MIN_IPA_SIZE):
|
||||
continue
|
||||
parsed = _parse_version(f.name)
|
||||
if parsed:
|
||||
version, build, version_tuple = parsed
|
||||
candidates.append((version_tuple, build, f.name))
|
||||
|
||||
def _select_latest_release(
|
||||
manifest: ReleaseManifest,
|
||||
platform: Literal["android", "ios"],
|
||||
channel: str,
|
||||
) -> ReleaseRecord | None:
|
||||
candidates = [
|
||||
release
|
||||
for release in manifest.releases
|
||||
if release.platform == platform and release.channel == channel
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
result = candidates[0]
|
||||
return result[2].replace("+", "."), result[1], result[2]
|
||||
return max(candidates, key=lambda release: release.version_code)
|
||||
|
||||
|
||||
def _compare_versions(
|
||||
current_version: str, current_build: int, latest_version: str, latest_build: int
|
||||
) -> tuple[bool, Literal["none", "optional", "required"]]:
|
||||
if current_build >= latest_build:
|
||||
return False, "none"
|
||||
def _build_download_url(file_name: str) -> str | None:
|
||||
base_url = config.app_version.download_base_url
|
||||
if base_url is None:
|
||||
return None
|
||||
|
||||
if current_build < latest_build - 2:
|
||||
return True, "required"
|
||||
return True, "optional"
|
||||
base_url_text = str(base_url).rstrip("/")
|
||||
path_prefix = config.app_version.release_path_prefix.strip().strip("/")
|
||||
if path_prefix:
|
||||
return f"{base_url_text}/{path_prefix}/{file_name}"
|
||||
return f"{base_url_text}/{file_name}"
|
||||
|
||||
|
||||
def _resolve_update_type(
|
||||
current_version_code: int,
|
||||
latest_version_code: int,
|
||||
min_supported_version_code: int,
|
||||
) -> Literal["none", "optional", "required"]:
|
||||
if current_version_code >= latest_version_code:
|
||||
return "none"
|
||||
if current_version_code < min_supported_version_code:
|
||||
return "required"
|
||||
return "optional"
|
||||
|
||||
|
||||
@router.get("/check-updates", response_model=AppVersionInfo)
|
||||
async def check_updates(
|
||||
current_version: str | None = Query(None, description="前端当前版本,如 0.1.0"),
|
||||
current_build: int | None = Query(None, description="前端当前构建号,如 1"),
|
||||
current_version_code: int = Query(
|
||||
..., ge=1, description="前端当前构建号(versionCode/buildNumber),如 1"
|
||||
),
|
||||
current_version_name: str = Query(
|
||||
"0.0.0",
|
||||
min_length=1,
|
||||
max_length=32,
|
||||
description="前端当前展示版本号,如 0.1.0",
|
||||
),
|
||||
platform: Literal["ios", "android"] = Query("ios", description="平台类型"),
|
||||
channel: str = Query(
|
||||
"release",
|
||||
min_length=1,
|
||||
max_length=32,
|
||||
pattern=r"^[a-z0-9_-]+$",
|
||||
description="发布渠道,如 release/beta",
|
||||
),
|
||||
) -> AppVersionInfo:
|
||||
current_build = current_build or 0
|
||||
manifest = _load_manifest()
|
||||
latest = _select_latest_release(manifest, platform=platform, channel=channel)
|
||||
|
||||
latest = _get_latest_release(platform)
|
||||
if not latest:
|
||||
if latest is None:
|
||||
return AppVersionInfo(
|
||||
has_update=False,
|
||||
latest_version=config.app_version.current_version,
|
||||
latest_build=config.app_version.current_build,
|
||||
min_required_version=config.app_version.current_version,
|
||||
update_type="none",
|
||||
latest_version_name=current_version_name,
|
||||
latest_version_code=current_version_code,
|
||||
min_supported_version_code=current_version_code,
|
||||
download_url=None,
|
||||
release_notes=None,
|
||||
file_name=None,
|
||||
file_size=None,
|
||||
sha256=None,
|
||||
)
|
||||
|
||||
latest_version, latest_build, filename = latest
|
||||
|
||||
has_update, update_type = _compare_versions(
|
||||
current_version or "0.0.0",
|
||||
current_build,
|
||||
latest_version,
|
||||
latest_build,
|
||||
update_type = _resolve_update_type(
|
||||
current_version_code=current_version_code,
|
||||
latest_version_code=latest.version_code,
|
||||
min_supported_version_code=latest.min_supported_version_code,
|
||||
)
|
||||
|
||||
download_url: str | None = None
|
||||
if has_update and config.app_version.download_base_url:
|
||||
download_url = f"{config.app_version.download_base_url.rstrip('/')}/{config.app_version.releases_dir}/{filename}"
|
||||
has_update = update_type != "none"
|
||||
|
||||
return AppVersionInfo(
|
||||
has_update=has_update,
|
||||
latest_version=latest_version,
|
||||
latest_build=latest_build,
|
||||
min_required_version=latest_version,
|
||||
update_type=update_type,
|
||||
download_url=download_url,
|
||||
release_notes="问题修复和体验优化",
|
||||
latest_version_name=latest.version_name,
|
||||
latest_version_code=latest.version_code,
|
||||
min_supported_version_code=latest.min_supported_version_code,
|
||||
download_url=_build_download_url(latest.file_name) if has_update else None,
|
||||
release_notes=latest.release_notes,
|
||||
file_name=latest.file_name,
|
||||
file_size=latest.file_size,
|
||||
sha256=latest.sha256,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Protocol, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select, update, delete
|
||||
from sqlalchemy import func, or_, select, update, delete
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.db.base_repository import BaseRepository
|
||||
@@ -39,6 +39,7 @@ class ScheduleItemRepository(Protocol):
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
query: str | None = None,
|
||||
) -> tuple[list[ScheduleItem], int]: ...
|
||||
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
|
||||
async def get_subscriptions_by_item_id(
|
||||
@@ -164,8 +165,12 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
query: str | None = None,
|
||||
) -> tuple[list[ScheduleItem], int]:
|
||||
offset = (page - 1) * page_size
|
||||
normalized_query = (query or "").strip()
|
||||
has_query = bool(normalized_query)
|
||||
query_like = f"%{normalized_query}%"
|
||||
try:
|
||||
count_stmt = (
|
||||
select(func.count())
|
||||
@@ -173,6 +178,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
|
||||
.where(ScheduleItem.owner_id == owner_id)
|
||||
.where(ScheduleItem.deleted_at.is_(None))
|
||||
)
|
||||
if has_query:
|
||||
count_stmt = count_stmt.where(
|
||||
or_(
|
||||
ScheduleItem.title.ilike(query_like),
|
||||
ScheduleItem.description.ilike(query_like),
|
||||
)
|
||||
)
|
||||
count_result = await self._session.execute(count_stmt)
|
||||
total = int(count_result.scalar_one() or 0)
|
||||
|
||||
@@ -184,6 +196,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
if has_query:
|
||||
items_stmt = items_stmt.where(
|
||||
or_(
|
||||
ScheduleItem.title.ilike(query_like),
|
||||
ScheduleItem.description.ilike(query_like),
|
||||
)
|
||||
)
|
||||
items_result = await self._session.execute(items_stmt)
|
||||
items = list(items_result.scalars().all())
|
||||
return items, total
|
||||
|
||||
@@ -269,6 +269,7 @@ class ScheduleItemService(BaseService):
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
query: str | None = None,
|
||||
) -> tuple[list[ScheduleItemResponse], int]:
|
||||
user_id = self.require_user_id()
|
||||
if page < 1:
|
||||
@@ -280,6 +281,7 @@ class ScheduleItemService(BaseService):
|
||||
user_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
query=query,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
|
||||
@@ -38,8 +38,7 @@ def test_tool_result_wire_event_with_bare_fields() -> None:
|
||||
"tool_call_id": "call-1",
|
||||
"tool_call_args": {"start_date": "2024-01-01"},
|
||||
"status": "success",
|
||||
"result_summary": "summary",
|
||||
"ui_schema": {"version": "2.0"},
|
||||
"result": "summary",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -50,8 +49,7 @@ def test_tool_result_wire_event_with_bare_fields() -> None:
|
||||
assert result["tool_name"] == "calendar_write"
|
||||
assert result["tool_call_id"] == "call-1"
|
||||
assert result["status"] == "success"
|
||||
assert result["result_summary"] == "summary"
|
||||
assert result["ui_schema"] == {"version": "2.0"}
|
||||
assert result["result"] == "summary"
|
||||
|
||||
|
||||
def test_text_end_event_with_bare_fields() -> None:
|
||||
@@ -124,7 +122,7 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None:
|
||||
assert "model" not in result
|
||||
|
||||
|
||||
def test_tool_call_result_agui_event_compiles_ui_hints_to_ui_schema() -> None:
|
||||
def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None:
|
||||
event = {
|
||||
"type": "TOOL_CALL_RESULT",
|
||||
"threadId": "thread-1",
|
||||
@@ -136,19 +134,20 @@ def test_tool_call_result_agui_event_compiles_ui_hints_to_ui_schema() -> None:
|
||||
"tool_call_id": "call-1",
|
||||
"tool_call_args": {"page": 1},
|
||||
"status": "success",
|
||||
"result_summary": "ok",
|
||||
"result": "ok",
|
||||
"ui_hints": {
|
||||
"intent": "status",
|
||||
"status": "success",
|
||||
"title": "Done",
|
||||
},
|
||||
"ui_schema": {"version": "2.0"},
|
||||
}
|
||||
|
||||
result = to_agui_wire_event(event)
|
||||
|
||||
assert result["type"] == "TOOL_CALL_RESULT"
|
||||
assert "ui_hints" not in result
|
||||
assert isinstance(result.get("ui_schema"), dict)
|
||||
assert "ui_schema" not in result
|
||||
|
||||
|
||||
def test_text_message_end_agui_event_compiles_ui_hints_to_ui_schema() -> None:
|
||||
|
||||
@@ -125,19 +125,19 @@ async def test_store_persists_tool_output_with_summary_as_content(
|
||||
"tool_call_id": "call-1",
|
||||
"tool_call_args": {"title": "A"},
|
||||
"status": "success",
|
||||
"result_summary": "已创建日程 A",
|
||||
"ui_hints": {
|
||||
"intent": "status",
|
||||
"status": "success",
|
||||
"sections": [],
|
||||
},
|
||||
"result": "status=success batch=1 success=1 failed=0 ids=[event-1]",
|
||||
}
|
||||
)
|
||||
|
||||
append_kwargs = cast(dict[str, Any], captured["append_kwargs"])
|
||||
assert getattr(append_kwargs["role"], "value", None) == "tool"
|
||||
assert append_kwargs["content"] == "已创建日程 A"
|
||||
assert (
|
||||
append_kwargs["content"]
|
||||
== "status=success batch=1 success=1 failed=0 ids=[event-1]"
|
||||
)
|
||||
metadata = cast(dict[str, Any], append_kwargs["metadata"])
|
||||
assert sorted(metadata.keys()) == ["run_id", "tool_agent_output"]
|
||||
assert metadata["tool_agent_output"]["result_summary"] == "已创建日程 A"
|
||||
assert metadata["tool_agent_output"]["ui_hints"]["intent"] == "status"
|
||||
assert (
|
||||
metadata["tool_agent_output"]["result"]
|
||||
== "status=success batch=1 success=1 failed=0 ids=[event-1]"
|
||||
)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.agentscope.events.tool_result_summary import build_tool_content_summary
|
||||
|
||||
|
||||
def test_summary_prioritizes_error() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "A"},
|
||||
result={"message": "ignored"},
|
||||
error={"message": "denied"},
|
||||
)
|
||||
assert text == "calendar_write 执行失败:denied"
|
||||
|
||||
|
||||
def test_summary_for_calendar_write() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "项目评审"},
|
||||
result={"startAt": "明天 10:00"},
|
||||
error=None,
|
||||
)
|
||||
assert text == "已创建日程:项目评审(明天 10:00)"
|
||||
|
||||
|
||||
def test_summary_for_calendar_read() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_read",
|
||||
args={"query": "今天"},
|
||||
result={"data": {"total": 3}},
|
||||
error=None,
|
||||
)
|
||||
assert text == "查询到 3 条日程(今天)"
|
||||
|
||||
|
||||
def test_summary_falls_back_to_result_content() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="unknown_tool",
|
||||
args=None,
|
||||
result={"content": "这是非常长的说明" * 20},
|
||||
error=None,
|
||||
)
|
||||
assert text.startswith("这是非常长的说明")
|
||||
assert len(text) <= 80
|
||||
|
||||
|
||||
def test_summary_default_done() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="unknown_tool",
|
||||
args=None,
|
||||
result=None,
|
||||
error=None,
|
||||
)
|
||||
assert text == "unknown_tool 执行完成"
|
||||
|
||||
|
||||
def test_summary_marks_business_failure_when_ok_false() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "上学"},
|
||||
result={
|
||||
"type": "calendar_operation.v1",
|
||||
"data": {
|
||||
"ok": False,
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "calendar.write requires validated user token",
|
||||
},
|
||||
},
|
||||
error=None,
|
||||
)
|
||||
assert (
|
||||
text == "calendar_write 执行失败:calendar.write requires validated user token"
|
||||
)
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from agentscope.tool import ToolResponse
|
||||
@@ -25,11 +25,30 @@ def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
|
||||
@dataclass
|
||||
class _FakeService:
|
||||
created_request: Any = None
|
||||
created_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
list_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
async def list_paginated(
|
||||
self, *, page: int, page_size: int, query: str | None = None
|
||||
):
|
||||
self.list_calls.append({"page": page, "page_size": page_size, "query": query})
|
||||
item = SimpleNamespace(
|
||||
id=UUID(self.created_id),
|
||||
title="会议",
|
||||
description="今天下午五点的会议",
|
||||
start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc),
|
||||
timezone="Asia/Shanghai",
|
||||
metadata=SimpleNamespace(
|
||||
location=None, color="#4F46E5", reminder_minutes=15
|
||||
),
|
||||
)
|
||||
return [item], 1
|
||||
|
||||
async def create_agent_generated(self, request):
|
||||
self.created_request = request
|
||||
return SimpleNamespace(
|
||||
id=uuid4(),
|
||||
id=UUID(self.created_id),
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
start_at=request.start_at,
|
||||
@@ -136,6 +155,8 @@ async def test_calendar_write_create_normalizes_to_utc(
|
||||
payload = _decode_tool_response(result)
|
||||
|
||||
assert payload["status"] == "success"
|
||||
assert payload["result"].startswith("status=success")
|
||||
assert fake_service.created_id in payload["result"]
|
||||
assert fake_service.created_request is not None
|
||||
request = fake_service.created_request
|
||||
assert request.timezone == "Asia/Shanghai"
|
||||
@@ -164,3 +185,28 @@ async def test_calendar_write_rejects_misaligned_batch_lists(
|
||||
assert payload["status"] == "failure"
|
||||
assert payload["error"]["code"] == "INVALID_ARGUMENT"
|
||||
assert "长度必须与 operations 一致" in payload["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_read_returns_structured_result_with_ids(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fake_service = _FakeService()
|
||||
monkeypatch.setattr(
|
||||
calendar_module, "create_schedule_service", lambda *_: fake_service
|
||||
)
|
||||
|
||||
result = await calendar_module.calendar_read(
|
||||
query="会议",
|
||||
page=1,
|
||||
page_size=20,
|
||||
session=SimpleNamespace(),
|
||||
owner_id=uuid4(),
|
||||
)
|
||||
payload = _decode_tool_response(result)
|
||||
|
||||
assert payload["status"] == "success"
|
||||
assert payload["result"].startswith("status=success")
|
||||
assert "query=会议" in payload["result"]
|
||||
assert fake_service.created_id in payload["result"]
|
||||
assert fake_service.list_calls == [{"page": 1, "page_size": 20, "query": "会议"}]
|
||||
|
||||
@@ -311,3 +311,74 @@ async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None:
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail == "Too many attachments"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_history_snapshot_filters_out_tool_messages() -> None:
|
||||
class _HistoryRepository(_FakeRepository):
|
||||
async def get_history_day(
|
||||
self, *, session_id: str, before: date | None
|
||||
) -> dict[str, object] | None:
|
||||
del session_id, before
|
||||
return {
|
||||
"day": "2026-03-17",
|
||||
"hasMore": False,
|
||||
"messages": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000111",
|
||||
"seq": 1,
|
||||
"role": "user",
|
||||
"content": "帮我查一下今天日程",
|
||||
"metadata": None,
|
||||
"timestamp": "2026-03-17T09:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000112",
|
||||
"seq": 2,
|
||||
"role": "tool",
|
||||
"content": "已获取日程列表,共 3 条",
|
||||
"metadata": {
|
||||
"run_id": "run-1",
|
||||
"tool_agent_output": {
|
||||
"tool_name": "calendar_read",
|
||||
"tool_call_id": "call-1",
|
||||
"status": "success",
|
||||
"result": "status=success total=3 returned=3",
|
||||
},
|
||||
},
|
||||
"timestamp": "2026-03-17T09:00:01Z",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000113",
|
||||
"seq": 3,
|
||||
"role": "assistant",
|
||||
"content": "今天共有 3 条日程。",
|
||||
"metadata": {
|
||||
"run_id": "run-1",
|
||||
"worker_agent_output": {
|
||||
"status": "success",
|
||||
"answer": "今天共有 3 条日程。",
|
||||
"key_points": [],
|
||||
"result_type": "summary",
|
||||
"suggested_actions": [],
|
||||
},
|
||||
},
|
||||
"timestamp": "2026-03-17T09:00:02Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
service = AgentService(
|
||||
repository=_HistoryRepository(),
|
||||
queue=_FakeQueue(),
|
||||
stream=_FakeStream(),
|
||||
attachment_storage=_FakeAttachmentStorage(),
|
||||
)
|
||||
|
||||
snapshot = await service.get_history_snapshot(
|
||||
thread_id="00000000-0000-0000-0000-000000000001",
|
||||
before=None,
|
||||
current_user=_user(),
|
||||
)
|
||||
|
||||
assert [message.role for message in snapshot.messages] == ["user", "assistant"]
|
||||
|
||||
@@ -16,21 +16,18 @@ class _FakeMessage:
|
||||
self.timestamp = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_convert_message_to_history_uses_ui_schema_key_for_tool_message() -> None:
|
||||
def test_convert_message_to_history_does_not_attach_ui_schema_for_tool_message() -> (
|
||||
None
|
||||
):
|
||||
message = _FakeMessage(
|
||||
role="tool",
|
||||
metadata={
|
||||
"tool_agent_output": {
|
||||
"ui_schema": {"version": "2.0", "root": {"type": "stack"}}
|
||||
}
|
||||
},
|
||||
metadata={"tool_agent_output": {"result": "done"}},
|
||||
)
|
||||
|
||||
result = convert_message_to_history(message) # type: ignore[arg-type]
|
||||
|
||||
assert "ui_schema" in result
|
||||
assert "ui_schema" not in result
|
||||
assert "uiSchema" not in result
|
||||
assert result["ui_schema"] == {"version": "2.0", "root": {"type": "stack"}}
|
||||
|
||||
|
||||
def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() -> None:
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from v1.app import router
|
||||
|
||||
|
||||
def _write_manifest(tmp_path, data: dict):
|
||||
manifest_file = tmp_path / "manifest.json"
|
||||
manifest_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
return manifest_file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_returns_none_when_manifest_missing(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
router, "_manifest_file_path", lambda: router.PROJECT_ROOT / "missing.json"
|
||||
)
|
||||
|
||||
result = await router.check_updates(
|
||||
current_version_code=2,
|
||||
current_version_name="0.1.1",
|
||||
platform="android",
|
||||
channel="release",
|
||||
)
|
||||
|
||||
assert result.has_update is False
|
||||
assert result.update_type == "none"
|
||||
assert result.latest_version_name == "0.1.1"
|
||||
assert result.latest_version_code == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_returns_optional_update(monkeypatch, tmp_path) -> None:
|
||||
manifest_file = _write_manifest(
|
||||
tmp_path,
|
||||
{
|
||||
"releases": [
|
||||
{
|
||||
"platform": "android",
|
||||
"channel": "release",
|
||||
"version_name": "0.1.2",
|
||||
"version_code": 3,
|
||||
"min_supported_version_code": 2,
|
||||
"file_name": "social-app-android-v0.1.2+3-release.apk",
|
||||
"release_notes": "优化体验",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(router, "_manifest_file_path", lambda: manifest_file)
|
||||
monkeypatch.setattr(
|
||||
router.config.app_version,
|
||||
"download_base_url",
|
||||
"https://download.example.com",
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
router.config.app_version,
|
||||
"release_path_prefix",
|
||||
"releases",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await router.check_updates(
|
||||
current_version_code=2,
|
||||
current_version_name="0.1.1",
|
||||
platform="android",
|
||||
channel="release",
|
||||
)
|
||||
|
||||
assert result.has_update is True
|
||||
assert result.update_type == "optional"
|
||||
assert result.latest_version_name == "0.1.2"
|
||||
assert result.latest_version_code == 3
|
||||
assert result.min_supported_version_code == 2
|
||||
assert (
|
||||
result.download_url
|
||||
== "https://download.example.com/releases/social-app-android-v0.1.2+3-release.apk"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_updates_returns_required_update(monkeypatch, tmp_path) -> None:
|
||||
manifest_file = _write_manifest(
|
||||
tmp_path,
|
||||
{
|
||||
"releases": [
|
||||
{
|
||||
"platform": "android",
|
||||
"channel": "release",
|
||||
"version_name": "0.1.3",
|
||||
"version_code": 5,
|
||||
"min_supported_version_code": 4,
|
||||
"file_name": "social-app-android-v0.1.3+5-release.apk",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(router, "_manifest_file_path", lambda: manifest_file)
|
||||
monkeypatch.setattr(
|
||||
router.config.app_version,
|
||||
"download_base_url",
|
||||
"https://download.example.com",
|
||||
raising=False,
|
||||
)
|
||||
|
||||
result = await router.check_updates(
|
||||
current_version_code=3,
|
||||
current_version_name="0.1.1",
|
||||
platform="android",
|
||||
channel="release",
|
||||
)
|
||||
|
||||
assert result.has_update is True
|
||||
assert result.update_type == "required"
|
||||
assert result.min_supported_version_code == 4
|
||||
Reference in New Issue
Block a user