refactor: 简化 AgentScope 运行时模块与事件处理
- 移除冗余的 user_token 参数传递 - 重构 tool.result 事件使用 ToolAgentOutput 模型 - 重构 text.end 事件使用 WorkerAgentOutput 模型 - 简化 store 模块的 tool result 处理逻辑 - 更新 router/service 适配新事件结构 - 清理废弃的测试文件与设计文档 - 新增 AgentRuns 多模态存储设计文档
This commit is contained in:
@@ -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 == {}
|
||||
|
||||
Reference in New Issue
Block a user