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
@@ -50,10 +50,13 @@ def test_tool_result_wire_event_filters_sensitive_fields() -> None:
"data": {
"messageId": "tool-result-1",
"toolCallId": "call-1",
"callId": "call-1",
"toolName": "calendar_write",
"content": "summary",
"ui": {"type": "calendar_operation.v1", "data": {"ok": True}},
"toolAgentOutput": {
"tool_name": "calendar_write",
"tool_call_id": "call-1",
"status": "success",
"result_summary": "summary",
"tool_call_args": {},
},
"args": {"token": "secret"},
"result": {"raw": "secret"},
"error": "stack trace",
@@ -65,9 +68,32 @@ def test_tool_result_wire_event_filters_sensitive_fields() -> None:
assert result["type"] == "TOOL_CALL_RESULT"
assert result["messageId"] == "tool-result-1"
assert result["toolCallId"] == "call-1"
assert result["toolName"] == "calendar_write"
assert result["content"] == "summary"
assert isinstance(result.get("ui"), dict)
assert isinstance(result.get("toolAgentOutput"), dict)
assert "args" not in result
assert "result" not in result
assert "error" not in result
def test_text_end_event_only_keeps_protocol_fields() -> None:
internal = {
"type": "text.end",
"threadId": "thread-1",
"runId": "run-1",
"data": {
"messageId": "assistant-run-1",
"workerAgentOutput": {"answer": "done", "status": "success"},
"stage": "worker",
"model": "qwen",
"inputTokens": 1,
"outputTokens": 2,
},
}
result = to_agui_wire_event(internal)
assert result["type"] == "TEXT_MESSAGE_END"
assert result["messageId"] == "assistant-run-1"
assert isinstance(result.get("workerAgentOutput"), dict)
assert "stage" not in result
assert "model" not in result
assert "inputTokens" not in result
@@ -49,49 +49,11 @@ class _FakeToolResultStorage:
return path
@pytest.mark.asyncio
async def test_store_marks_session_running_on_run_started(
def _patch_repositories(
monkeypatch: pytest.MonkeyPatch,
captured: dict[str, object],
fake_chat_session: Any,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot=None)
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
async def get_session(self, *, session_id): # noqa: ANN001
captured["session_id"] = session_id
return fake_chat_session
async def update_runtime_state(self, **kwargs): # noqa: ANN003
captured.update(kwargs)
monkeypatch.setattr(store_module, "SessionRepository", _FakeSessionRepository)
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx())
await store.persist(
{
"type": "RUN_STARTED",
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-1",
}
)
assert captured["status"] == _SessionStatus.RUNNING
assert captured["message_delta"] == 0
assert captured["token_delta"] == 0
assert captured["cost_delta"] == Decimal("0")
@pytest.mark.asyncio
async def test_store_persists_assistant_message_and_aggregates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot={"k": "v"}, message_count=6)
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
@@ -118,6 +80,14 @@ async def test_store_persists_assistant_message_and_aggregates(
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
@pytest.mark.asyncio
async def test_store_persists_worker_output_with_answer_as_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=6)
_patch_repositories(monkeypatch, captured, fake_chat_session)
store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx())
await store.persist(
@@ -127,7 +97,7 @@ async def test_store_persists_assistant_message_and_aggregates(
"runId": "run-1",
"messageId": "assistant-run-1",
"role": "assistant",
"stage": "report",
"stage": "worker",
}
)
await store.persist(
@@ -136,7 +106,7 @@ async def test_store_persists_assistant_message_and_aggregates(
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-1",
"messageId": "assistant-run-1",
"delta": "hello",
"delta": "legacy-text",
}
)
await store.persist(
@@ -149,177 +119,34 @@ async def test_store_persists_assistant_message_and_aggregates(
"outputTokens": 5,
"cost": "0.123",
"latencyMs": 250,
"workerAgentOutput": {
"status": "success",
"answer": "worker-answer",
"key_points": [],
"result_type": "summary",
"suggested_actions": [],
"error": None,
},
}
)
append_kwargs = cast(dict[str, Any], captured["append_kwargs"])
assert append_kwargs["seq"] == 7
assert append_kwargs["content"] == "hello"
assert append_kwargs["input_tokens"] == 3
assert append_kwargs["output_tokens"] == 5
assert append_kwargs["content"] == "worker-answer"
metadata = cast(dict[str, Any], append_kwargs["metadata"])
assert metadata["worker_agent_output"]["answer"] == "worker-answer"
assert append_kwargs["cost"] == Decimal("0.123")
assert append_kwargs["metadata"]["latency_ms"] == 250
assert append_kwargs["metadata"]["stage"] == "report"
assert append_kwargs["latency_ms"] == 250
assert captured["message_delta"] == 1
assert captured["token_delta"] == 8
assert captured["cost_delta"] == Decimal("0.123")
@pytest.mark.asyncio
async def test_store_uses_canonical_thread_id_for_buffer_keys(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=1)
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
async def get_session(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def lock_session_for_update(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def update_runtime_state(self, **kwargs): # noqa: ANN003
captured.update(kwargs)
class _FakeMessageRepository:
def __init__(self, session: object) -> None:
del session
async def append_message(self, **kwargs): # noqa: ANN003
captured["append_kwargs"] = kwargs
monkeypatch.setattr(store_module, "SessionRepository", _FakeSessionRepository)
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx())
compact_thread_id = "00000000000000000000000000000001"
await store.persist(
{
"type": "TEXT_MESSAGE_CONTENT",
"threadId": compact_thread_id,
"runId": "run-1",
"messageId": "assistant-run-1",
"delta": "hello",
}
)
await store.persist(
{
"type": "TEXT_MESSAGE_END",
"threadId": compact_thread_id,
"runId": "run-1",
"messageId": "assistant-run-1",
}
)
append_kwargs = cast(dict[str, Any], captured["append_kwargs"])
assert append_kwargs["content"] == "hello"
@pytest.mark.asyncio
async def test_store_clears_buffer_on_run_finished(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=0)
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
async def get_session(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def lock_session_for_update(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def update_runtime_state(self, **kwargs): # noqa: ANN003
captured.update(kwargs)
class _FakeMessageRepository:
def __init__(self, session: object) -> None:
del session
async def append_message(self, **kwargs): # noqa: ANN003
captured["append_kwargs"] = kwargs
monkeypatch.setattr(store_module, "SessionRepository", _FakeSessionRepository)
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx())
thread_id = "00000000-0000-0000-0000-000000000001"
await store.persist(
{
"type": "TEXT_MESSAGE_CONTENT",
"threadId": thread_id,
"runId": "run-1",
"messageId": "assistant-run-1",
"delta": "stale",
}
)
await store.persist(
{
"type": "RUN_FINISHED",
"threadId": thread_id,
"runId": "run-1",
}
)
await store.persist(
{
"type": "TEXT_MESSAGE_END",
"threadId": thread_id,
"runId": "run-1",
"messageId": "assistant-run-1",
}
)
assert "append_kwargs" not in captured
@pytest.mark.asyncio
async def test_store_persists_tool_call_result_as_tool_message(
async def test_store_persists_tool_output_with_summary_as_content(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=2)
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
async def get_session(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def lock_session_for_update(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def update_runtime_state(self, **kwargs): # noqa: ANN003
captured.update(kwargs)
class _FakeMessageRepository:
def __init__(self, session: object) -> None:
del session
async def append_message(self, **kwargs): # noqa: ANN003
captured["append_kwargs"] = kwargs
monkeypatch.setattr(store_module, "SessionRepository", _FakeSessionRepository)
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
_patch_repositories(monkeypatch, captured, fake_chat_session)
fake_storage = _FakeToolResultStorage()
store = store_module.SqlAlchemyEventStore(
@@ -334,128 +161,23 @@ async def test_store_persists_tool_call_result_as_tool_message(
"runId": "run-1",
"toolName": "calendar_write",
"taskId": "t1",
"stage": "execution",
"args": {"title": "A"},
"result": {"event_id": "evt-1", "token": "secret"},
"stage": "worker",
"toolAgentOutput": {
"tool_name": "calendar_write",
"tool_call_id": "call-1",
"tool_call_args": {"title": "A"},
"status": "success",
"result_summary": "已创建日程 A",
"ui_hints": None,
"error": None,
},
}
)
append_kwargs = cast(dict[str, Any], captured["append_kwargs"])
assert getattr(append_kwargs["role"], "value", None) == "tool"
assert append_kwargs["tool_name"] == "calendar_write"
assert append_kwargs["metadata"]["task_id"] == "t1"
tool_call_id = append_kwargs["metadata"]["tool_call_id"]
assert isinstance(tool_call_id, str)
assert tool_call_id.startswith("run-1-t1-")
assert append_kwargs["metadata"]["storage_bucket"] == "agent-tool-results"
assert isinstance(append_kwargs["metadata"]["storage_path"], str)
assert append_kwargs["content"].startswith("已创建日程")
assert append_kwargs["content"] == "已创建日程 A"
metadata = cast(dict[str, Any], append_kwargs["metadata"])
assert metadata["tool_agent_output"]["result_summary"] == "已创建日程 A"
assert metadata["storage_bucket"] == "agent-tool-results"
assert len(fake_storage.upload_calls) == 1
uploaded = fake_storage.upload_calls[0]
assert uploaded["bucket"] == "agent-tool-results"
payload = cast(dict[str, Any], uploaded["payload"])
assert payload["toolName"] == "calendar_write"
assert "args" not in payload
assert isinstance(payload.get("result"), dict)
assert payload["result"]["token"] == "[REDACTED]"
assert captured["message_delta"] == 1
@pytest.mark.asyncio
async def test_store_sanitizes_nested_sensitive_fields_in_result_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=0)
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
async def get_session(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def lock_session_for_update(self, *, session_id): # noqa: ANN001
del session_id
return fake_chat_session
async def update_runtime_state(self, **kwargs): # noqa: ANN003
captured.update(kwargs)
class _FakeMessageRepository:
def __init__(self, session: object) -> None:
del session
async def append_message(self, **kwargs): # noqa: ANN003
captured["append_kwargs"] = kwargs
monkeypatch.setattr(store_module, "SessionRepository", _FakeSessionRepository)
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
fake_storage = _FakeToolResultStorage()
store = store_module.SqlAlchemyEventStore(
session_factory=lambda: _FakeSessionCtx(),
tool_result_storage=fake_storage,
tool_result_bucket="agent-tool-results",
)
await store.persist(
{
"type": "TOOL_CALL_RESULT",
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-1",
"toolName": "calendar_write",
"result": {
"data": {
"ok": True,
"accessToken": "secret-a",
"nested": {
"refresh_token": "secret-b",
},
"items": [
{"authorizationHeader": "secret-c"},
],
}
},
}
)
payload = cast(dict[str, Any], fake_storage.upload_calls[0]["payload"])
stored_result = cast(dict[str, Any], payload["result"])
data = cast(dict[str, Any], stored_result["data"])
assert data["accessToken"] == "[REDACTED]"
nested = cast(dict[str, Any], data["nested"])
assert nested["refresh_token"] == "[REDACTED]"
items = cast(list[Any], data["items"])
assert isinstance(items[0], dict)
assert items[0]["authorizationHeader"] == "[REDACTED]"
@pytest.mark.asyncio
async def test_store_drops_buffer_when_session_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakeSessionRepository:
def __init__(self, session: object) -> None:
del session
async def get_session(self, *, session_id): # noqa: ANN001
del session_id
return None
monkeypatch.setattr(store_module, "SessionRepository", _FakeSessionRepository)
store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx())
thread_id = "00000000-0000-0000-0000-000000000001"
await store.persist(
{
"type": "TEXT_MESSAGE_CONTENT",
"threadId": thread_id,
"messageId": "assistant-run-1",
"delta": "orphan",
}
)
assert store._message_buffers == {}