refactor: 简化 AgentScope 运行时模块与事件处理

- 移除冗余的 user_token 参数传递
- 重构 tool.result 事件使用 ToolAgentOutput 模型
- 重构 text.end 事件使用 WorkerAgentOutput 模型
- 简化 store 模块的 tool result 处理逻辑
- 更新 router/service 适配新事件结构
- 清理废弃的测试文件与设计文档
- 新增 AgentRuns 多模态存储设计文档
This commit is contained in:
qzl
2026-03-13 17:27:18 +08:00
parent 3273d63b23
commit 1c02503d1d
29 changed files with 1259 additions and 2725 deletions
@@ -38,16 +38,13 @@ def to_agui_wire_event(event: dict[str, Any]) -> dict[str, Any]:
data = event.get("data")
if isinstance(data, dict):
if event_type == "tool.result":
for key in (
"messageId",
"toolCallId",
"callId",
"toolName",
"stage",
"taskId",
"ui",
"content",
):
for key in ("messageId", "toolCallId", "toolAgentOutput"):
value = data.get(key)
if value is not None:
payload[key] = value
return payload
if event_type == "text.end":
for key in ("messageId", "workerAgentOutput"):
value = data.get(key)
if value is not None:
payload[key] = value
+30 -93
View File
@@ -1,16 +1,19 @@
from __future__ import annotations
import json
import re
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Protocol
from uuid import UUID, uuid4
from core.agentscope.events.tool_result_summary import build_tool_content_summary
from core.agentscope.events.persistence import MessageRepository, SessionRepository
from core.logging import get_logger
from models.agent_chat_message import AgentChatMessageRole
from models.agent_chat_session import AgentChatSessionStatus
from schemas.agent.runtime_models import (
ToolAgentOutput,
WorkerAgentOutputLite,
WorkerAgentOutputRich,
)
class EventStore(Protocol):
@@ -193,6 +196,19 @@ class SqlAlchemyEventStore:
if isinstance(stage, str) and stage:
metadata["stage"] = stage
worker_payload = event.get("workerAgentOutput")
if isinstance(worker_payload, dict):
try:
if "ui_hints" in worker_payload:
worker_output = WorkerAgentOutputRich.model_validate(worker_payload)
else:
worker_output = WorkerAgentOutputLite.model_validate(worker_payload)
except Exception:
worker_output = None
else:
content = worker_output.answer
metadata["worker_agent_output"] = worker_output.model_dump(mode="json")
role_value = context.get("role")
if not isinstance(role_value, str):
role_value = "assistant"
@@ -252,6 +268,14 @@ class SqlAlchemyEventStore:
if not isinstance(tool_name, str) or not tool_name:
return
raw_output = event.get("toolAgentOutput")
if not isinstance(raw_output, dict):
return
try:
tool_output = ToolAgentOutput.model_validate(raw_output)
except Exception:
return
run_id = event.get("runId")
run_id_value = run_id if isinstance(run_id, str) and run_id else ""
task_id = event.get("taskId")
@@ -264,43 +288,18 @@ class SqlAlchemyEventStore:
else f"{task_id_value}-{uuid4().hex[:8]}"
)
summary = build_tool_content_summary(
tool_name=tool_name,
args=event.get("args") if isinstance(event.get("args"), dict) else None,
result=event.get("result"),
error=event.get("error"),
)
raw_result_value = event.get("result")
raw_result: dict[str, object] = (
raw_result_value if isinstance(raw_result_value, dict) else {}
)
ui_candidate = raw_result.get("ui")
ui_schema = ui_candidate if isinstance(ui_candidate, dict) else None
result_type = raw_result.get("type")
result_data = raw_result.get("data")
if (
ui_schema is None
and isinstance(result_type, str)
and isinstance(result_data, dict)
):
ui_schema = raw_result
payload: dict[str, object] = {
"toolName": tool_name,
"ui_schema": ui_schema,
"result": _sanitize_result(raw_result),
"error": _sanitize_error(event.get("error")),
"toolAgentOutput": tool_output.model_dump(mode="json"),
"callId": call_id_value,
"runId": run_id_value,
"taskId": task_id_value,
"content": summary,
"content": tool_output.result_summary,
}
metadata: dict[str, object] = {
"tool_name": tool_name,
"tool_call_id": call_id_value,
"summary_version": "v1",
"tool_agent_output": tool_output.model_dump(mode="json"),
}
if run_id_value:
metadata["run_id"] = run_id_value
@@ -332,9 +331,7 @@ class SqlAlchemyEventStore:
storage_path=storage_path,
)
content = summary or json.dumps(
payload, ensure_ascii=False, separators=(",", ":")
)
content = tool_output.result_summary
locked_session = await session_repo.lock_session_for_update(
session_id=session_id
@@ -429,63 +426,3 @@ def _sanitize_path_component(value: str) -> str:
compact = re.sub(r"[^A-Za-z0-9._-]", "-", value.strip())
compact = compact.strip(".-")
return compact or "id"
def _sanitize_error(value: object) -> str | None:
if isinstance(value, str) and value.strip():
return " ".join(value.split())[:300]
if isinstance(value, dict):
for key in ("message", "error", "detail"):
item = value.get(key)
if isinstance(item, str) and item.strip():
return " ".join(item.split())[:300]
return None
def _sanitize_result(value: object) -> dict[str, object]:
if not isinstance(value, dict):
return {}
def _is_sensitive_key(key: str) -> bool:
normalized = key.strip().lower().replace("-", "_")
if not normalized:
return False
exact = {
"password",
"token",
"secret",
"api_key",
"apikey",
"credential",
"authorization",
"auth",
}
if normalized in exact:
return True
patterns = (
"password",
"token",
"secret",
"auth",
"credential",
"api_key",
"apikey",
"authorization",
)
return any(pattern in normalized for pattern in patterns)
def _sanitize_value(item: object) -> object:
if isinstance(item, dict):
return _sanitize_result(item)
if isinstance(item, list):
return [_sanitize_value(entry) for entry in item]
return item
sanitized: dict[str, object] = {}
for key, item in value.items():
key_text = str(key)
if _is_sensitive_key(key_text):
sanitized[str(key)] = "[REDACTED]"
continue
sanitized[str(key)] = _sanitize_value(item)
return sanitized
@@ -78,21 +78,19 @@ def build_intent_user_prompt(
*, user_input: str | list[dict[str, Any]]
) -> str | list[dict[str, Any]]:
if isinstance(user_input, list):
instruction_block = {
"type": "text",
"text": "\n\n".join(
[
ROUTER_STAGE_INSTRUCTION,
"[Output Schema]",
_schema_json(RouterAgentOutput),
]
),
}
return [
{
"type": "text",
"text": "\n\n".join(
[
ROUTER_STAGE_INSTRUCTION,
"[Output Schema]",
_schema_json(RouterAgentOutput),
"[User Input]",
json.dumps(
user_input, ensure_ascii=True, separators=(",", ":")
),
]
),
}
instruction_block,
*user_input,
]
return "\n\n".join(
[
@@ -50,11 +50,9 @@ class AgentScopeRuntimeOrchestrator:
*,
command: RunAgentInput,
owner_id: UUID,
user_token: str,
user_context: UserContext,
session: AsyncSession,
) -> dict[str, Any]:
del user_token
return await self._execute(
command=command,
owner_id=owner_id,
@@ -68,11 +66,9 @@ class AgentScopeRuntimeOrchestrator:
*,
command: RunAgentInput,
owner_id: UUID,
user_token: str,
user_context: UserContext,
session: AsyncSession,
) -> dict[str, Any]:
del user_token
return await self._execute(
command=command,
owner_id=owner_id,
@@ -116,7 +112,7 @@ class AgentScopeRuntimeOrchestrator:
user_input = _to_resume_user_input(command)
else:
_, content_blocks = extract_latest_user_payload(command)
user_input = _to_user_input_payload(content_blocks)
user_input = _to_model_user_input(content_blocks)
router_toolkit = build_stage_toolkit(
stage="intent",
session=session,
@@ -159,16 +155,38 @@ class AgentScopeRuntimeOrchestrator:
worker_payload = result.get("worker") if isinstance(result, dict) else None
worker = worker_payload if isinstance(worker_payload, dict) else {}
response_metadata = worker.get("response_metadata")
metadata = response_metadata if isinstance(response_metadata, dict) else {}
assistant_text = _resolve_worker_answer(worker)
tool_outputs_raw = worker.get("tool_outputs")
if isinstance(tool_outputs_raw, list):
for idx, item in enumerate(tool_outputs_raw, start=1):
if not isinstance(item, dict):
continue
tool_name = item.get("tool_name")
tool_call_id = item.get("tool_call_id")
if not isinstance(tool_name, str) or not tool_name:
continue
if not isinstance(tool_call_id, str) or not tool_call_id:
tool_call_id = f"{run_id}-tool-{idx}"
await self._pipeline.emit(
session_id=thread_id,
event={
"type": "tool.result",
"threadId": thread_id,
"runId": run_id,
"data": {
"messageId": f"tool-{tool_call_id}",
"toolCallId": tool_call_id,
"toolAgentOutput": item,
},
},
)
await self._emit_stage_text(
thread_id=thread_id,
run_id=run_id,
stage_name="worker",
message_id=f"assistant-{run_id}",
text=assistant_text,
response_metadata=metadata,
worker_agent_output=worker,
)
await self._pipeline.emit(
@@ -215,7 +233,7 @@ class AgentScopeRuntimeOrchestrator:
stage_name: str,
message_id: str,
text: str,
response_metadata: dict[str, Any],
worker_agent_output: dict[str, Any],
) -> None:
await self._pipeline.emit(
session_id=thread_id,
@@ -250,8 +268,7 @@ class AgentScopeRuntimeOrchestrator:
"runId": run_id,
"data": {
"messageId": message_id,
"stage": stage_name,
**_text_end_telemetry_payload(response_metadata),
"workerAgentOutput": worker_agent_output,
},
},
)
@@ -271,6 +288,28 @@ def _to_user_input_payload(
return content_blocks
def _to_model_user_input(
content_blocks: list[dict[str, Any]],
) -> str | list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for block in content_blocks:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if block_type == "text":
text = block.get("text")
if isinstance(text, str) and text.strip():
normalized.append({"type": "text", "text": text})
continue
if block_type != "binary":
continue
url = block.get("url")
if isinstance(url, str) and url:
normalized.append({"type": "image_url", "image_url": {"url": url}})
return _to_user_input_payload(normalized)
def _to_resume_user_input(command: RunAgentInput) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for message in command.messages:
@@ -296,66 +335,3 @@ def _resolve_worker_answer(worker: dict[str, Any]) -> str:
return message
return "抱歉,这次没有产出可用结果,请重试。"
def _text_end_telemetry_payload(metadata: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
model = _first_non_empty_str(metadata, keys=("model", "model_code"))
if model is not None:
payload["model"] = model
input_tokens = _first_number(metadata, keys=("inputTokens", "input_tokens"))
if input_tokens is not None:
payload["inputTokens"] = input_tokens
output_tokens = _first_number(metadata, keys=("outputTokens", "output_tokens"))
if output_tokens is not None:
payload["outputTokens"] = output_tokens
latency_ms = _first_number(metadata, keys=("latencyMs", "latency_ms"))
if latency_ms is not None:
payload["latencyMs"] = latency_ms
cost = _first_number(metadata, keys=("cost", "total_cost"), allow_float=True)
if cost is not None:
payload["cost"] = cost
return payload
def _first_non_empty_str(
metadata: dict[str, Any], *, keys: tuple[str, ...]
) -> str | None:
for key in keys:
value = metadata.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _first_number(
metadata: dict[str, Any],
*,
keys: tuple[str, ...],
allow_float: bool = False,
) -> int | float | None:
for key in keys:
value = metadata.get(key)
if isinstance(value, bool):
continue
if isinstance(value, int):
if value < 0:
continue
return value
if isinstance(value, float):
if value < 0:
continue
return value if allow_float else int(value)
if isinstance(value, str):
try:
parsed = float(value) if allow_float else int(value)
except ValueError:
continue
if parsed >= 0:
return parsed
return None
@@ -68,16 +68,6 @@ def _build_user_context(*, owner_id: UUID, run_input: RunAgentInput) -> UserCont
)
def _extract_user_token(
*, command: dict[str, Any], run_input: RunAgentInput
) -> str | None:
del run_input
raw_token = command.get("user_token")
if isinstance(raw_token, str) and raw_token.strip():
return raw_token.strip()
return None
async def _build_recent_context_messages(
*,
session: Any,
@@ -147,7 +137,6 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
if command_type == "resume":
extract_latest_tool_result(parsed_run_input)
user_context = _build_user_context(owner_id=owner_id, run_input=parsed_run_input)
user_token = _extract_user_token(command=command, run_input=parsed_run_input) or ""
redis_client = await get_or_init_redis_client()
bus = RedisStreamBus(
@@ -189,7 +178,6 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
await runtime.resume(
command=parsed_run_input,
owner_id=owner_id,
user_token=user_token,
user_context=user_context,
session=session,
)
@@ -197,7 +185,6 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
await runtime.run(
command=parsed_run_input,
owner_id=owner_id,
user_token=user_token,
user_context=user_context,
session=session,
)
@@ -167,16 +167,18 @@ class UiCompiler:
timestamp: str | None = None,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
hints = output.ui_hints or self._build_default_worker_hints(output)
if output.error is not None and not self._contains_error_block(hints.blocks):
output_ui_hints = getattr(output, "ui_hints", None)
hints = output_ui_hints or self._build_default_worker_hints(output)
output_error = getattr(output, "error", None)
if output_error is not None and not self._contains_error_block(hints.blocks):
hints = self._append_error_block(
hints,
UiHintErrorBlock(
kind="error",
errorCode=output.error.code,
message=output.error.message,
retryable=output.error.retryable,
details=self._stringify_details(output.error.details),
errorCode=output_error.code,
message=output_error.message,
retryable=output_error.retryable,
details=self._stringify_details(output_error.details),
),
)
@@ -0,0 +1,57 @@
from __future__ import annotations
import json
from typing import Protocol
from services.base.supabase import supabase_service
class ToolResultStorage(Protocol):
async def upload_json(
self,
*,
bucket: str,
path: str,
payload: dict[str, object],
) -> str: ...
async def read_json(
self,
*,
bucket: str,
path: str,
) -> dict[str, object] | None: ...
class SupabaseToolResultStorage:
async def upload_json(
self,
*,
bucket: str,
path: str,
payload: dict[str, object],
) -> str:
serialized = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
await supabase_service.upload_bytes(
bucket=bucket,
path=path,
content=serialized.encode("utf-8"),
content_type="application/json",
)
return path
async def read_json(
self,
*,
bucket: str,
path: str,
) -> dict[str, object] | None:
raw = await supabase_service.download_bytes(bucket=bucket, path=path)
decoded = json.loads(raw.decode("utf-8"))
if isinstance(decoded, dict):
return decoded
return None
def create_tool_result_storage() -> ToolResultStorage:
return SupabaseToolResultStorage()