refactor(agent): remove memory agent, simplify runtime config system

This commit is contained in:
zl-q
2026-03-23 01:20:27 +08:00
parent 80ad5141a6
commit 3aacc756db
43 changed files with 1210 additions and 1312 deletions
@@ -6,6 +6,7 @@ import pytest
from ag_ui.core import RunAgentInput
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
from schemas.automation import MemoryContextConfig, RuntimeConfig
from schemas.user import UserContext, parse_profile_settings
@@ -42,11 +43,18 @@ def _run_input() -> RunAgentInput:
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {"agent_type": "worker"},
"forwardedProps": {"runtime_mode": "automation"},
}
)
def _runtime_config() -> RuntimeConfig:
return RuntimeConfig(
enabled_tools=[],
context=MemoryContextConfig(),
)
@pytest.mark.asyncio
async def test_orchestrator_emits_run_lifecycle_events() -> None:
pipeline = _FakePipeline()
@@ -58,7 +66,7 @@ async def test_orchestrator_emits_run_lifecycle_events() -> None:
run_input=_run_input(),
context_messages=[],
user_context=_user_context(),
system_agent_mode="worker",
runtime_config=_runtime_config(),
)
assert result["worker"]["answer"] == "done"
@@ -5,7 +5,6 @@ from ag_ui.core import RunAgentInput
import core.agentscope.runtime.runner as runner_module
from core.agentscope.runtime.runner import AgentScopeRunner
from schemas.automation.config import default_memory_job_config
from schemas.agent.runtime_models import (
ExecutionMode,
NormalizedTaskInput,
@@ -19,6 +18,7 @@ from schemas.agent.runtime_models import (
WorkerAgentOutputLite,
)
from schemas.agent.system_agent import AgentType
from schemas.automation import MemoryContextConfig, RuntimeConfig
from schemas.user import UserContext, parse_profile_settings
@@ -31,7 +31,7 @@ def _run_input() -> RunAgentInput:
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {"agent_type": "worker"},
"forwardedProps": {"runtime_mode": "automation"},
}
)
@@ -45,10 +45,20 @@ def _user_context() -> UserContext:
)
def _runtime_config() -> RuntimeConfig:
return RuntimeConfig(
enabled_tools=[],
context=MemoryContextConfig(),
)
def test_build_worker_input_messages_only_contains_router_contract() -> None:
runner = AgentScopeRunner()
router_output = RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(user_text="安排明天会议"),
normalized_task_input=NormalizedTaskInput(
user_text="安排明天会议",
context_summary="用户询问天气",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
@@ -67,6 +77,43 @@ def test_build_worker_input_messages_only_contains_router_contract() -> None:
assert "[RouterAgentOutput]" in str(input_messages[0].content)
def test_build_router_messages_injects_user_input_when_context_last_not_user() -> None:
runner = AgentScopeRunner()
run_input = _run_input()
messages = runner._build_router_messages(
context_messages=[],
run_input=run_input,
)
assert len(messages) == 1
assert messages[0].role == "user"
assert messages[0].content == "hello"
def test_build_router_messages_skips_injection_when_context_last_is_user() -> None:
runner = AgentScopeRunner()
run_input = _run_input()
from agentscope.message import Msg
existing_context = [
Msg(name="user", role="user", content="之前的问题"),
Msg(name="assistant", role="assistant", content="回答"),
Msg(name="user", role="user", content="最新用户消息"),
]
messages = runner._build_router_messages(
context_messages=existing_context,
run_input=run_input,
)
assert len(messages) == len(existing_context)
for i, msg in enumerate(messages):
assert msg.role == existing_context[i].role
assert msg.content == existing_context[i].content
@pytest.mark.asyncio
async def test_resolve_runtime_client_time_from_forwarded_props() -> None:
runner = AgentScopeRunner()
@@ -79,7 +126,7 @@ async def test_resolve_runtime_client_time_from_forwarded_props() -> None:
"tools": [],
"context": [],
"forwardedProps": {
"agent_type": "worker",
"runtime_mode": "automation",
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
@@ -95,7 +142,7 @@ async def test_resolve_runtime_client_time_from_forwarded_props() -> None:
@pytest.mark.asyncio
async def test_execute_worker_mode_runs_router_then_worker(
async def test_execute_runs_router_then_worker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakePipeline:
@@ -127,7 +174,10 @@ async def test_execute_worker_mode_runs_router_then_worker(
async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput:
del kwargs
return RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(user_text="安排会议"),
normalized_task_input=NormalizedTaskInput(
user_text="安排会议",
context_summary="用户询问天气",
),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
@@ -145,7 +195,7 @@ async def test_execute_worker_mode_runs_router_then_worker(
monkeypatch.setattr(runner_module, "AsyncSessionLocal", lambda: _FakeSessionCtx())
monkeypatch.setattr(runner, "_load_stage_config", _fake_load_stage_config)
monkeypatch.setattr(runner, "_build_stage_toolkit", lambda **kwargs: object())
monkeypatch.setattr(runner, "_build_toolkit", lambda **kwargs: object())
monkeypatch.setattr(runner, "_execute_router_step", _fake_execute_router_step)
monkeypatch.setattr(runner, "_execute_worker_step", _fake_execute_worker_step)
@@ -154,84 +204,9 @@ async def test_execute_worker_mode_runs_router_then_worker(
context_messages=[],
pipeline=_FakePipeline(),
run_input=_run_input(),
system_agent_mode="worker",
runtime_config=_runtime_config(),
)
assert load_calls == [AgentType.ROUTER, AgentType.WORKER]
assert result["router"]["normalized_task_input"]["user_text"] == "安排会议"
assert result["worker"]["answer"] == "ok"
@pytest.mark.asyncio
async def test_execute_memory_mode_requires_memory_job_config() -> None:
runner = AgentScopeRunner()
class _FakePipeline:
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
del session_id, event
return "1-0"
with pytest.raises(RuntimeError, match="memory job config is required"):
await runner.execute(
user_context=_user_context(),
context_messages=[],
pipeline=_FakePipeline(),
run_input=_run_input(),
system_agent_mode="memory",
memory_job_config=None,
)
@pytest.mark.asyncio
async def test_execute_memory_mode_uses_memory_job_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakePipeline:
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
del session_id, event
return "1-0"
class _FakeSessionCtx:
async def __aenter__(self) -> object:
return object()
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
del exc_type, exc, tb
runner = AgentScopeRunner()
async def _fake_build_memory_stage_config(**kwargs: object):
del kwargs
return runner_module.SystemAgentRuntimeConfig(
agent_type=AgentType.MEMORY,
model_code="qwen3.5-flash",
api_base_url="https://example.com",
api_key="test",
llm_config=runner_module.SystemAgentLLMConfig(),
)
async def _fake_execute_single_stage_step(**kwargs: object):
del kwargs
return runner_module.AgentOutput(answer="memory")
monkeypatch.setattr(runner_module, "AsyncSessionLocal", lambda: _FakeSessionCtx())
monkeypatch.setattr(
runner, "_build_memory_stage_config", _fake_build_memory_stage_config
)
monkeypatch.setattr(runner, "_build_stage_toolkit", lambda **kwargs: object())
monkeypatch.setattr(
runner,
"_execute_single_stage_step",
_fake_execute_single_stage_step,
)
result = await runner.execute(
user_context=_user_context(),
context_messages=[],
pipeline=_FakePipeline(),
run_input=_run_input(),
system_agent_mode="memory",
memory_job_config=default_memory_job_config(),
)
assert result["memory"]["answer"] == "memory"
@@ -7,6 +7,7 @@ import pytest
import core.agentscope.runtime.tasks as tasks_module
from schemas.agent import ToolStatus
from schemas.automation import ContextWindowMode, MemoryContextConfig
from schemas.user import UserContext, parse_profile_settings
@@ -15,21 +16,36 @@ def _run_input_payload() -> dict[str, Any]:
"threadId": str(uuid4()),
"runId": "run-1",
"state": {},
"messages": [],
"messages": [{"id": "u1", "role": "user", "content": "现在几点"}],
"tools": [],
"context": [],
"forwardedProps": {"agent_type": "worker"},
"forwardedProps": {"runtime_mode": "automation"},
}
class _FakeSessionCtx:
async def __aenter__(self) -> object:
return object()
async def __aenter__(self) -> "_FakeSession":
return _FakeSession()
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
del exc_type, exc, tb
class _FakeSession:
async def execute(self, stmt: object) -> object:
del stmt
class FakeResult:
def scalars(self) -> object:
class FakeScalars:
def all(self) -> list[object]:
return []
return FakeScalars()
return FakeResult()
async def _fake_user_context(**kwargs: object) -> UserContext:
del kwargs
return UserContext(
@@ -81,6 +97,10 @@ async def test_run_agentscope_task_calls_runtime_run(
"command": "run",
"owner_id": str(uuid4()),
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
)
@@ -89,34 +109,28 @@ async def test_run_agentscope_task_calls_runtime_run(
@pytest.mark.asyncio
async def test_run_agentscope_task_includes_recent_context_messages(
async def test_run_agentscope_task_injects_runtime_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_messages: list[dict[str, Any]] = []
captured_config: dict[str, Any] = {}
class _FakeRuntime:
def __init__(self, **kwargs: object) -> None:
del kwargs
async def run(self, **kwargs: object) -> object:
raw_context_messages = kwargs.get("context_messages")
raw_run_input = kwargs.get("run_input")
if isinstance(raw_context_messages, list):
captured_messages.extend(raw_context_messages)
if raw_run_input is not None:
raw_messages = getattr(raw_run_input, "messages", [])
if isinstance(raw_messages, list):
captured_messages.extend(raw_messages)
captured_config.update(
{
"runtime_config": kwargs.get("runtime_config"),
"context_messages": kwargs.get("context_messages"),
}
)
return object()
async def _fake_get_redis_client() -> object:
return object()
async def _empty_context(**kwargs: object) -> list[dict[str, Any]]:
del kwargs
return []
async def _fake_context(**kwargs: object) -> list[dict[str, Any]]:
del kwargs
return [{"id": "ctx-1", "role": "assistant", "content": "历史上下文"}]
@@ -133,25 +147,23 @@ async def test_run_agentscope_task_includes_recent_context_messages(
"_build_recent_context_messages",
_empty_context,
)
monkeypatch.setattr(
tasks_module,
"_build_recent_context_messages",
_fake_context,
)
run_input = _run_input_payload()
run_input["messages"] = [{"id": "u1", "role": "user", "content": "现在几点"}]
await tasks_module.run_agentscope_task(
{
"command": "run",
"owner_id": str(uuid4()),
"run_input": run_input,
"run_input": _run_input_payload(),
"runtime_config": {
"enabled_tools": [],
"context": {"window_mode": "day", "window_count": 2},
},
}
)
assert len(captured_messages) == 2
assert captured_messages[0]["id"] == "ctx-1"
assert getattr(captured_messages[1], "id", None) == "u1"
assert captured_config["context_messages"] == [
{"id": "ctx-1", "role": "assistant", "content": "历史上下文"}
]
assert captured_config["runtime_config"] is not None
@pytest.mark.asyncio
@@ -177,38 +189,6 @@ async def test_run_agentscope_task_rejects_invalid_command_type() -> None:
)
@pytest.mark.asyncio
async def test_run_agentscope_task_requires_forwarded_props_agent_type() -> None:
payload = _run_input_payload()
payload["forwardedProps"] = {}
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
await tasks_module.run_agentscope_task(
{
"command": "run",
"owner_id": str(uuid4()),
"run_input": payload,
}
)
@pytest.mark.asyncio
async def test_run_agentscope_task_memory_mode_requires_automation_job_id() -> None:
payload = _run_input_payload()
payload["forwardedProps"] = {"agent_type": "memory"}
with pytest.raises(
ValueError, match="automation_job_id is required for memory mode"
):
await tasks_module.run_agentscope_task(
{
"command": "run",
"owner_id": str(uuid4()),
"run_input": payload,
}
)
@pytest.mark.asyncio
async def test_build_recent_context_messages_includes_all_user_attachments(
monkeypatch: pytest.MonkeyPatch,
@@ -221,9 +201,9 @@ async def test_build_recent_context_messages_includes_all_user_attachments(
self,
*,
thread_id: str,
system_agent_mode: str,
context_config: MemoryContextConfig,
) -> dict[str, object] | None:
del thread_id, system_agent_mode
del thread_id, context_config
return {
"messages": [
{
@@ -257,7 +237,10 @@ async def test_build_recent_context_messages_includes_all_user_attachments(
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
context_config=MemoryContextConfig(
window_mode=ContextWindowMode.DAY,
window_count=2,
),
)
assert len(messages) == 1
@@ -281,9 +264,9 @@ async def test_build_recent_context_messages_uses_tool_metadata_output(
self,
*,
thread_id: str,
system_agent_mode: str,
context_config: MemoryContextConfig,
) -> dict[str, object] | None:
del thread_id, system_agent_mode
del thread_id, context_config
return {
"messages": [
{
@@ -312,7 +295,7 @@ async def test_build_recent_context_messages_uses_tool_metadata_output(
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
context_config=MemoryContextConfig(),
)
assert len(messages) == 1
@@ -336,9 +319,9 @@ async def test_build_recent_context_messages_skips_tool_without_metadata_output(
self,
*,
thread_id: str,
system_agent_mode: str,
context_config: MemoryContextConfig,
) -> dict[str, object] | None:
del thread_id, system_agent_mode
del thread_id, context_config
return {
"messages": [
{
@@ -354,17 +337,17 @@ async def test_build_recent_context_messages_skips_tool_without_metadata_output(
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
context_config=MemoryContextConfig(),
)
assert messages == []
@pytest.mark.asyncio
async def test_build_recent_context_messages_passes_context_mode_through(
async def test_build_recent_context_messages_passes_context_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_mode: dict[str, str | None] = {"mode": None}
captured_config: dict[str, Any] = {"config": None}
class _FakeContextService:
def __init__(self, *, repository: object) -> None:
@@ -374,19 +357,21 @@ async def test_build_recent_context_messages_passes_context_mode_through(
self,
*,
thread_id: str,
system_agent_mode: str,
context_config: MemoryContextConfig,
) -> dict[str, object] | None:
del thread_id
captured_mode["mode"] = system_agent_mode
captured_config["config"] = context_config
return None
monkeypatch.setattr(tasks_module, "AgentContextService", _FakeContextService)
cfg = MemoryContextConfig(window_mode=ContextWindowMode.NUMBER, window_count=10)
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
context_config=cfg,
)
assert messages == []
assert captured_mode["mode"] == "worker"
assert captured_config["config"].window_mode == ContextWindowMode.NUMBER
assert captured_config["config"].window_count == 10
@@ -23,19 +23,17 @@ def test_build_agent_prompt_for_worker_contains_runtime_config() -> None:
assert "enabled_tools=calendar.read,calendar.write" in prompt
def test_build_agent_prompt_for_memory_uses_memory_rules() -> None:
def test_build_agent_prompt_for_router_contains_task_typing_rules() -> None:
prompt = build_agent_prompt(
agent_type=AgentType.MEMORY,
agent_type=AgentType.ROUTER,
llm_config=SystemAgentLLMConfig.model_validate(
{
"context_messages": {"mode": "day", "count": 2},
"enabled_tools": ["user.lookup"],
}
),
)
assert "- type: memory" in prompt
assert "[Memory Agent]" in prompt
assert "- type: router" in prompt
assert "[Router Agent]" in prompt
assert "context_messages.mode=day" in prompt
assert "context_messages.count=2" in prompt
assert "enabled_tools=user.lookup" in prompt
@@ -156,3 +156,48 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication
assert "[Answer Style]" in prompt
assert "Default reply language:" not in prompt
assert "Follow agent contracts strictly" not in prompt
def test_build_system_prompt_includes_memory_section_when_memories_provided() -> None:
from schemas.memories import (
MemoryContext,
MemoryListResponse,
MemorySource,
MemoryType,
)
memories = MemoryListResponse(
owner_id=uuid4(),
memories=[
MemoryContext(
memory_type=MemoryType.USER,
source=MemorySource.MANUAL,
title="User prefers morning meetings",
content={"text": "User likes meetings before 10am"},
created_at=datetime(2026, 3, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 3, 1, tzinfo=timezone.utc),
),
],
total=1,
)
prompt = build_system_prompt(
agent_type=AgentType.WORKER,
user_context=_build_user_context(),
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
memories=memories,
)
assert "<!-- MEMORY_START -->" in prompt
assert "[User Memories]" in prompt
assert "User prefers morning meetings" in prompt
def test_build_system_prompt_omits_memory_section_when_no_memories() -> None:
prompt = build_system_prompt(
agent_type=AgentType.WORKER,
user_context=_build_user_context(),
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
)
assert "<!-- MEMORY_START -->" not in prompt
@@ -5,17 +5,23 @@ from uuid import UUID, uuid4
import pytest
from core.automation.scheduler import (
AutomationSchedulerService,
_compute_next_run_at,
from models.automation_jobs import AutomationJob as OrmAutomationJob, ScheduleType
from schemas.automation import (
RuntimeConfig,
)
from models.automation_jobs import ScheduleType
from schemas.automation.config import AutomationJobConfig
from schemas.automation.scheduler import DueAutomationJob
from v1.automation_jobs.service import AutomationJobsService, _compute_next_run_at
class _FakeSession:
async def commit(self) -> None:
pass
async def rollback(self) -> None:
pass
class _FakeRepository:
def __init__(self, jobs: list[DueAutomationJob]) -> None:
def __init__(self, jobs: list[OrmAutomationJob]) -> None:
self.jobs = jobs
self.marked: list[tuple[UUID, datetime, datetime]] = []
self.commits = 0
@@ -23,30 +29,14 @@ class _FakeRepository:
async def list_due_jobs(
self, *, now_utc: datetime, limit: int
) -> list[DueAutomationJob]:
) -> list[OrmAutomationJob]:
del now_utc
return self.jobs[:limit]
async def get_job_config(self, *, job_id: UUID) -> AutomationJobConfig:
del job_id
return AutomationJobConfig.model_validate(
{
"agent_type": "memory",
"model_code": "qwen3.5-flash",
"enabled_tools": ["calendar.read", "user.lookup"],
"input_template": "auto input",
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
}
)
async def ensure_latest_chat_session(self, *, owner_id: UUID) -> UUID:
async def get_or_create_chat_session(self, *, owner_id: UUID) -> UUID:
return owner_id
async def mark_job_dispatched(
async def update_job_schedule(
self,
*,
job_id: UUID,
@@ -55,57 +45,65 @@ class _FakeRepository:
) -> None:
self.marked.append((job_id, next_run_at, last_run_at))
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
class _FakeQueue:
def __init__(self) -> None:
self.commands: list[dict[str, object]] = []
async def enqueue(
self,
*,
command: dict[str, object],
dedup_key: str | None,
) -> str:
del dedup_key
self.commands.append(command)
return "task-1"
def _make_orm_job(
*,
job_id: UUID | None = None,
owner_id: UUID | None = None,
schedule_type: ScheduleType = ScheduleType.DAILY,
next_run_at: datetime | None = None,
) -> OrmAutomationJob:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
return OrmAutomationJob(
id=job_id or uuid4(),
owner_id=owner_id or uuid4(),
title="Test Job",
config={
"enabled_tools": ["calendar.read", "user.lookup"],
"context": {
"source": "latest_chat",
"window_mode": "day",
"window_count": 2,
},
"input_template": "auto input: {date}",
},
schedule_type=schedule_type,
run_at=now - timedelta(hours=1),
next_run_at=next_run_at or now - timedelta(minutes=1),
timezone="UTC",
last_run_at=None,
status="active",
created_by=None,
created_at=now - timedelta(days=1),
updated_at=now - timedelta(hours=1),
deleted_at=None,
)
@pytest.mark.asyncio
async def test_scan_and_dispatch_enqueues_memory_run_command() -> None:
async def test_scan_and_dispatch_calls_dispatch_fn_with_runtime_config() -> None:
now = datetime(2026, 3, 19, 12, 0, tzinfo=timezone.utc)
owner_id = uuid4()
job_id = uuid4()
repo = _FakeRepository(
jobs=[
DueAutomationJob(
id=job_id,
owner_id=owner_id,
schedule_type=ScheduleType.DAILY,
timezone="UTC",
next_run_at=now - timedelta(minutes=1),
)
]
)
queue = _FakeQueue()
service = AutomationSchedulerService(repository=repo, queue=queue)
repo = _FakeRepository(jobs=[_make_orm_job(job_id=job_id, owner_id=owner_id)])
dispatched_calls: list[dict] = []
result = await service.scan_and_dispatch(now_utc=now, limit=10)
async def dispatch_fn(**kwargs: object) -> None:
dispatched_calls.append(kwargs)
service = AutomationJobsService(repository=repo, session=_FakeSession())
result = await service.scan_and_dispatch(
now_utc=now, limit=10, dispatch_fn=dispatch_fn
)
assert result.scanned == 1
assert result.dispatched == 1
assert len(queue.commands) == 1
run_input = queue.commands[0]["run_input"]
assert isinstance(run_input, dict)
assert run_input["forwardedProps"] == {"agent_type": "memory"}
assert queue.commands[0]["automation_job_id"] == str(job_id)
assert repo.commits == 1
assert len(dispatched_calls) == 1
assert dispatched_calls[0]["owner_id"] == owner_id
assert dispatched_calls[0]["runtime_config"] is not None
cfg: RuntimeConfig = dispatched_calls[0]["runtime_config"]
assert len(cfg.enabled_tools) == 2
def test_compute_next_run_at_daily() -> None: