refactor: 重构 Agent 模块为 AgentScope,删除旧版 CrewAI/LiteLLM 实现

This commit is contained in:
qzl
2026-03-11 20:51:56 +08:00
parent 177ed616bf
commit 145e3dc615
149 changed files with 5120 additions and 11356 deletions
@@ -0,0 +1,284 @@
from __future__ import annotations
from decimal import Decimal
from enum import Enum
from types import SimpleNamespace
from typing import Any, cast
import pytest
from core.agentscope.events import store as store_module
class _SessionStatus(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class _FakeSessionCtx:
class _Session:
async def commit(self) -> None:
return None
async def __aenter__(self) -> object:
return self._Session()
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
del exc_type, exc, tb
@pytest.mark.asyncio
async def test_store_marks_session_running_on_run_started(
monkeypatch: pytest.MonkeyPatch,
) -> 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
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())
await store.persist(
{
"type": "TEXT_MESSAGE_CONTENT",
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-1",
"messageId": "assistant-run-1",
"delta": "hello",
}
)
await store.persist(
{
"type": "TEXT_MESSAGE_END",
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-1",
"messageId": "assistant-run-1",
"inputTokens": 3,
"outputTokens": 5,
"cost": "0.123",
"latencyMs": 250,
}
)
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["cost"] == Decimal("0.123")
assert append_kwargs["metadata"]["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_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 == {}
@@ -0,0 +1,163 @@
from __future__ import annotations
from uuid import uuid4
import pytest
from core.agentscope.persistence.user_context_cache import UserContextCache
from core.agentscope.schemas.user_context import (
UserAgentContext,
parse_profile_settings,
)
class _FakeRedis:
def __init__(self) -> None:
self.store: dict[str, dict[str, str]] = {}
self.set_store: dict[str, set[str]] = {}
self.expire_calls: list[tuple[str, int]] = []
self.delete_calls: list[str] = []
self.hincrby_calls: list[tuple[str, str, int]] = []
async def hgetall(self, key: str) -> dict[str, str]:
return dict(self.store.get(key, {}))
async def hset(self, key: str, mapping: dict[str, str]) -> int:
self.store[key] = dict(mapping)
return 1
async def hincrby(self, key: str, field: str, amount: int = 1) -> int:
self.hincrby_calls.append((key, field, amount))
data = self.store.setdefault(key, {})
current = int(data.get(field, "0"))
next_value = current + amount
data[field] = str(next_value)
return next_value
async def expire(self, key: str, seconds: int) -> int:
self.expire_calls.append((key, seconds))
return 1
async def delete(self, *keys: str) -> int:
for key in keys:
self.delete_calls.append(key)
self.store.pop(key, None)
self.set_store.pop(key, None)
return len(keys)
async def sadd(self, key: str, *values: str) -> int:
bucket = self.set_store.setdefault(key, set())
before = len(bucket)
for value in values:
bucket.add(value)
return len(bucket) - before
async def smembers(self, key: str) -> set[str]:
return set(self.set_store.get(key, set()))
class _BrokenRedis:
async def hgetall(self, key: str) -> dict[str, str]:
del key
raise RuntimeError("redis down")
async def hset(self, key: str, mapping: dict[str, str]) -> int:
del key, mapping
raise RuntimeError("redis down")
async def hincrby(self, key: str, field: str, amount: int = 1) -> int:
del key, field, amount
raise RuntimeError("redis down")
async def expire(self, key: str, seconds: int) -> int:
del key, seconds
raise RuntimeError("redis down")
async def delete(self, *keys: str) -> int:
del keys
raise RuntimeError("redis down")
async def sadd(self, key: str, *values: str) -> int:
del key, values
raise RuntimeError("redis down")
async def smembers(self, key: str) -> set[str]:
del key
raise RuntimeError("redis down")
def _build_context() -> UserAgentContext:
return UserAgentContext(
user_id=uuid4(),
username="demo-user",
bio="demo bio",
settings=parse_profile_settings({"preferences": {"ai_language": "en-US"}}),
)
@pytest.mark.asyncio
async def test_user_context_cache_set_and_get_hit() -> None:
redis = _FakeRedis()
cache = UserContextCache(
client=redis,
key_prefix="agent:user-context",
ttl_seconds=600,
max_turns=3,
)
session_id = uuid4()
context = _build_context()
await cache.set(session_id=session_id, context=context)
loaded = await cache.get(session_id=session_id)
assert loaded is not None
assert loaded.user_id == context.user_id
assert loaded.username == "demo-user"
assert redis.expire_calls == [
(f"agent:user-context:{session_id}", 600),
(f"agent:user-context:sessions:{context.user_id}", 600),
]
assert redis.hincrby_calls == [
(f"agent:user-context:{session_id}", "turns_used", 1)
]
@pytest.mark.asyncio
async def test_user_context_cache_invalidate_user_deletes_all_sessions() -> None:
redis = _FakeRedis()
cache = UserContextCache(
client=redis,
key_prefix="agent:user-context",
ttl_seconds=600,
max_turns=3,
)
context = _build_context()
s1 = uuid4()
s2 = uuid4()
await cache.set(session_id=s1, context=context)
await cache.set(session_id=s2, context=context)
deleted = await cache.invalidate_user(user_id=context.user_id)
assert deleted == 2
assert f"agent:user-context:{s1}" in redis.delete_calls
assert f"agent:user-context:{s2}" in redis.delete_calls
assert f"agent:user-context:sessions:{context.user_id}" in redis.delete_calls
@pytest.mark.asyncio
async def test_user_context_cache_degrades_gracefully_on_redis_error() -> None:
cache = UserContextCache(
client=_BrokenRedis(),
key_prefix="agent:user-context",
ttl_seconds=600,
max_turns=3,
)
session_id = uuid4()
context = _build_context()
loaded = await cache.get(session_id=session_id)
await cache.set(session_id=session_id, context=context)
assert loaded is None
@@ -6,7 +6,10 @@ from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.schemas.user_context import (
UserAgentContext,
parse_profile_settings,
)
from core.agentscope.runtime.agent_route_runtime import AgentRouteRuntime
from core.agentscope.schemas import ReportOutput, RuntimeOutput
from core.agentscope.schemas.agent_runtime import RunCommand
@@ -7,8 +7,11 @@ from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.domain.system_agent_config import SystemAgentLLMConfig
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.schemas.system_agent_config import SystemAgentLLMConfig
from core.agentscope.schemas.user_context import (
UserAgentContext,
parse_profile_settings,
)
from core.agentscope.runtime.config_loader import RuntimeStageConfig
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
@@ -5,7 +5,7 @@ from types import SimpleNamespace
import pytest
from core.agent.domain.system_agent_config import SystemAgentLLMConfig
from core.agentscope.schemas.system_agent_config import SystemAgentLLMConfig
from core.agentscope.runtime.config_loader import RuntimeStageConfig
from core.agentscope.runtime.react_runner import (
AgentScopeReActRunner,
@@ -0,0 +1,96 @@
from __future__ import annotations
import pytest
from core.agentscope.schemas.agui_input import (
MAX_MESSAGES,
MAX_RUN_ID_LENGTH,
MAX_RUN_INPUT_BYTES,
MAX_TEXT_CHARS,
extract_latest_tool_result,
parse_run_input,
validate_run_request_messages_contract,
)
def _base_payload() -> dict[str, object]:
return {
"threadId": "00000000-0000-0000-0000-000000000001",
"runId": "run-1",
"state": {},
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {},
}
def test_parse_run_input_rejects_invalid_uuid() -> None:
payload = _base_payload()
payload["threadId"] = "bad-uuid"
with pytest.raises(ValueError, match="threadId must be a valid UUID"):
parse_run_input(payload)
def test_parse_run_input_rejects_message_count_over_limit() -> None:
payload = _base_payload()
payload["messages"] = [
{"id": f"u{i}", "role": "user", "content": "x"} for i in range(MAX_MESSAGES + 1)
]
with pytest.raises(ValueError, match="RunAgentInput.messages exceeds limit"):
parse_run_input(payload)
def test_parse_run_input_rejects_user_text_over_limit() -> None:
payload = _base_payload()
payload["messages"] = [
{"id": "u1", "role": "user", "content": "x" * (MAX_TEXT_CHARS + 1)}
]
with pytest.raises(
ValueError, match="RunAgentInput user message text exceeds limit"
):
parse_run_input(payload)
def test_parse_run_input_rejects_payload_over_limit() -> None:
payload = _base_payload()
payload["forwardedProps"] = {"blob": "x" * MAX_RUN_INPUT_BYTES}
with pytest.raises(ValueError, match="RunAgentInput payload exceeds size limit"):
parse_run_input(payload)
def test_parse_run_input_rejects_run_id_over_limit() -> None:
payload = _base_payload()
payload["runId"] = "r" * (MAX_RUN_ID_LENGTH + 1)
with pytest.raises(ValueError, match="runId exceeds length limit"):
parse_run_input(payload)
def test_extract_latest_tool_result_requires_tool_call_id() -> None:
run_input = parse_run_input(_base_payload())
with pytest.raises(
ValueError,
match="RunAgentInput.messages requires a tool message with toolCallId for resume",
):
extract_latest_tool_result(run_input)
def test_validate_run_request_messages_contract_requires_single_user_message() -> None:
payload = _base_payload()
payload["messages"] = [
{"id": "u1", "role": "user", "content": "hello"},
{"id": "u2", "role": "user", "content": "again"},
]
run_input = parse_run_input(payload)
with pytest.raises(
ValueError,
match="RunAgentInput.messages must contain exactly one user message",
):
validate_run_request_messages_contract(run_input)
@@ -0,0 +1,36 @@
from __future__ import annotations
from pathlib import Path
def test_active_agentscope_paths_do_not_import_core_agent() -> None:
root = Path(__file__).resolve().parents[4]
targets = [
root / "src" / "core" / "agentscope",
root / "src" / "v1" / "agent",
]
offenders: list[str] = []
for target in targets:
for py_file in target.rglob("*.py"):
text = py_file.read_text(encoding="utf-8")
if "core.agent." in text:
offenders.append(str(py_file.relative_to(root)))
assert offenders == []
def test_active_app_paths_do_not_import_core_agent() -> None:
root = Path(__file__).resolve().parents[4]
targets = [
root / "src" / "v1" / "users" / "service.py",
root / "src" / "core" / "config" / "initial" / "init_data.py",
]
offenders: list[str] = []
for target in targets:
text = target.read_text(encoding="utf-8")
if "core.agent." in text:
offenders.append(str(target.relative_to(root)))
assert offenders == []
@@ -3,7 +3,10 @@ from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.schemas.user_context import (
UserAgentContext,
parse_profile_settings,
)
from core.agentscope.prompts.system_prompt import build_system_prompt