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:
qzl
2026-03-17 12:18:09 +08:00
parent c26cdbbc27
commit aa30fe0ce6
44 changed files with 984 additions and 655 deletions
+20
View File
@@ -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
+2 -3
View File
@@ -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,
+29 -12
View File
@@ -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
+5 -6
View File
@@ -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."
+6 -2
View File
@@ -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")
+2
View File
@@ -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)
-45
View File
@@ -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
View File
@@ -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,
)
+20 -1
View File
@@ -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
+2
View File
@@ -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"]
+5 -8
View File
@@ -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:
+119
View File
@@ -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