feat(agentscope): add memory system and automation job support

- Add consumer_registry and pipeline_registry for runtime orchestration
- Add Visibility schema for message filtering
- Add PipelineSpec for agent pipeline configuration
- Add automation job models and configuration
- Remove memory_prompt.py (consolidated into memory system)
- Update runtime components: context_loader, context_service, orchestrator, runner, tasks
- Update toolkit: tool_config, tool_middleware, custom tools (calendar, user_lookup)
- Add auth_helpers and calendar_domain utilities
- Add system_agents.yaml configuration
This commit is contained in:
qzl
2026-03-19 18:42:35 +08:00
parent 0661016827
commit 0abf51e837
55 changed files with 2172 additions and 1233 deletions
@@ -0,0 +1,28 @@
from __future__ import annotations
import pytest
from core.agentscope.runtime.consumer_registry import build_consumer_registry
def test_build_consumer_registry_from_system_agent_configs() -> None:
registry = build_consumer_registry(
system_agent_configs={
"router": {"config": {"visibility_consumer_bit": 16}},
"worker": {"config": {"visibility_consumer_bit": 17}},
"memory": {"config": {"visibility_consumer_bit": 18}},
}
)
assert registry.resolve_agent_bit(agent_type="router") == 16
assert registry.resolve_agent_bit(agent_type="worker") == 17
def test_build_consumer_registry_rejects_duplicate_bit() -> None:
with pytest.raises(ValueError, match="duplicate visibility bit"):
build_consumer_registry(
system_agent_configs={
"router": {"config": {"visibility_consumer_bit": 16}},
"worker": {"config": {"visibility_consumer_bit": 16}},
}
)
@@ -28,7 +28,7 @@ def _user_context() -> UserContext:
return UserContext(
id="00000000-0000-0000-0000-000000000001",
username="alice",
email="alice@example.com",
phone="+8613900000000",
settings=parse_profile_settings(None),
)
@@ -42,7 +42,7 @@ def _run_input() -> RunAgentInput:
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {},
"forwardedProps": {"agent_type": "worker"},
}
)
@@ -58,6 +58,7 @@ async def test_orchestrator_emits_run_lifecycle_events() -> None:
run_input=_run_input(),
context_messages=[],
user_context=_user_context(),
system_agent_mode="worker",
)
assert result["worker"]["answer"] == "done"
@@ -0,0 +1,24 @@
from __future__ import annotations
import pytest
from core.agentscope.runtime.pipeline_registry import build_default_pipeline_spec
def test_build_default_pipeline_spec_worker_has_two_stages() -> None:
spec = build_default_pipeline_spec(mode="worker")
assert spec.mode == "worker"
assert [item.stage_name for item in spec.stages] == ["router", "worker"]
def test_build_default_pipeline_spec_memory_has_single_stage() -> None:
spec = build_default_pipeline_spec(mode="memory")
assert spec.mode == "memory"
assert [item.stage_name for item in spec.stages] == ["memory"]
def test_build_default_pipeline_spec_rejects_unknown_mode() -> None:
with pytest.raises(ValueError, match="unsupported pipeline mode"):
build_default_pipeline_spec(mode="planner")
@@ -3,8 +3,23 @@ from __future__ import annotations
import pytest
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,
ResultType,
ResultTyping,
RouterAgentOutput,
RouterUiDecision,
TaskType,
TaskTyping,
UiMode,
WorkerAgentOutputLite,
)
from schemas.agent.system_agent import AgentType
from schemas.user import UserContext, parse_profile_settings
def _run_input() -> RunAgentInput:
@@ -16,19 +31,51 @@ def _run_input() -> RunAgentInput:
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {},
"forwardedProps": {"agent_type": "worker"},
}
)
def test_resolve_stage_agent_type_defaults_to_worker() -> None:
assert AgentScopeRunner._resolve_stage_agent_type("") == AgentType.WORKER
assert AgentScopeRunner._resolve_stage_agent_type("worker") == AgentType.WORKER
assert AgentScopeRunner._resolve_stage_agent_type("unknown") == AgentType.WORKER
def _user_context() -> UserContext:
return UserContext(
id="00000000-0000-0000-0000-000000000001",
username="alice",
phone="+8613900000000",
settings=parse_profile_settings(None),
)
def test_resolve_stage_agent_type_supports_memory() -> None:
assert AgentScopeRunner._resolve_stage_agent_type("memory") == AgentType.MEMORY
def test_parse_agent_type_supports_known_stages() -> None:
assert AgentScopeRunner._parse_agent_type(stage_name="router") == AgentType.ROUTER
assert AgentScopeRunner._parse_agent_type(stage_name="worker") == AgentType.WORKER
assert AgentScopeRunner._parse_agent_type(stage_name="memory") == AgentType.MEMORY
def test_parse_agent_type_rejects_unknown_stage() -> None:
with pytest.raises(ValueError, match="unsupported stage name"):
AgentScopeRunner._parse_agent_type(stage_name="planner")
def test_build_worker_input_messages_only_contains_router_contract() -> None:
runner = AgentScopeRunner()
router_output = RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(user_text="安排明天会议"),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
ui=RouterUiDecision(
ui_mode=UiMode.NONE,
ui_decision_reason="单一执行任务,文本输出足够",
),
)
input_messages = runner._build_worker_input_messages(router_output=router_output)
assert len(input_messages) == 1
assert input_messages[0].role == "user"
assert "[RouterAgentOutput]" in str(input_messages[0].content)
@pytest.mark.asyncio
@@ -43,11 +90,12 @@ async def test_resolve_runtime_client_time_from_forwarded_props() -> None:
"tools": [],
"context": [],
"forwardedProps": {
"agent_type": "worker",
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000,
}
},
},
}
)
@@ -55,3 +103,146 @@ async def test_resolve_runtime_client_time_from_forwarded_props() -> None:
resolved = runner._resolve_runtime_client_time(run_input=run_input)
assert resolved is not None
assert resolved.device_timezone == "America/Los_Angeles"
@pytest.mark.asyncio
async def test_execute_worker_mode_runs_router_then_worker(
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()
load_calls: list[AgentType] = []
async def _fake_load_stage_config(*, session: object, agent_type: AgentType):
del session
load_calls.append(agent_type)
return runner_module.SystemAgentRuntimeConfig(
agent_type=agent_type,
model_code="demo",
api_base_url="https://example.com",
api_key="test",
llm_config=runner_module.SystemAgentLLMConfig(),
)
async def _fake_execute_router_step(**kwargs: object) -> RouterAgentOutput:
del kwargs
return RouterAgentOutput(
normalized_task_input=NormalizedTaskInput(user_text="安排会议"),
key_entities=[],
constraints=[],
task_typing=TaskTyping(primary=TaskType.SCHEDULING),
execution_mode=ExecutionMode.TOOL_ASSISTED,
result_typing=ResultTyping(primary=ResultType.EXECUTION_REPORT),
ui=RouterUiDecision(
ui_mode=UiMode.NONE,
ui_decision_reason="单任务",
),
)
async def _fake_execute_worker_step(**kwargs: object) -> WorkerAgentOutputLite:
del kwargs
return WorkerAgentOutputLite(answer="ok")
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, "_execute_router_step", _fake_execute_router_step)
monkeypatch.setattr(runner, "_execute_worker_step", _fake_execute_worker_step)
result = await runner.execute(
user_context=_user_context(),
context_messages=[],
pipeline=_FakePipeline(),
run_input=_run_input(),
system_agent_mode="worker",
)
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"
@@ -18,7 +18,7 @@ def _run_input_payload() -> dict[str, Any]:
"messages": [],
"tools": [],
"context": [],
"forwardedProps": {},
"forwardedProps": {"agent_type": "worker"},
}
@@ -35,7 +35,7 @@ async def _fake_user_context(**kwargs: object) -> UserContext:
return UserContext(
id=str(uuid4()),
username="alice",
email="alice@example.com",
phone="+8613900000000",
settings=parse_profile_settings(None),
)
@@ -177,17 +177,53 @@ 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,
) -> None:
class _FakeAgentService:
async def load_agent_input_messages(
class _FakeContextService:
def __init__(self, *, repository: object) -> None:
del repository
async def load_context_messages(
self,
*,
thread_id: str,
system_agent_mode: str,
) -> dict[str, object] | None:
del thread_id
del thread_id, system_agent_mode
return {
"messages": [
{
@@ -215,14 +251,13 @@ async def test_build_recent_context_messages_includes_all_user_attachments(
async def download_bytes(self, *, bucket: str, path: str) -> bytes:
return f"{bucket}:{path}".encode("utf-8")
monkeypatch.setattr(
tasks_module, "get_agent_service", lambda session: _FakeAgentService()
)
monkeypatch.setattr(tasks_module, "AgentContextService", _FakeContextService)
monkeypatch.setattr(tasks_module, "supabase_service", _FakeSupabase())
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
)
assert len(messages) == 1
@@ -238,13 +273,17 @@ async def test_build_recent_context_messages_includes_all_user_attachments(
async def test_build_recent_context_messages_uses_tool_metadata_output(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakeAgentService:
async def load_agent_input_messages(
class _FakeContextService:
def __init__(self, *, repository: object) -> None:
del repository
async def load_context_messages(
self,
*,
thread_id: str,
system_agent_mode: str,
) -> dict[str, object] | None:
del thread_id
del thread_id, system_agent_mode
return {
"messages": [
{
@@ -268,13 +307,12 @@ async def test_build_recent_context_messages_uses_tool_metadata_output(
]
}
monkeypatch.setattr(
tasks_module, "get_agent_service", lambda session: _FakeAgentService()
)
monkeypatch.setattr(tasks_module, "AgentContextService", _FakeContextService)
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
)
assert len(messages) == 1
@@ -290,13 +328,17 @@ async def test_build_recent_context_messages_uses_tool_metadata_output(
async def test_build_recent_context_messages_skips_tool_without_metadata_output(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakeAgentService:
async def load_agent_input_messages(
class _FakeContextService:
def __init__(self, *, repository: object) -> None:
del repository
async def load_context_messages(
self,
*,
thread_id: str,
system_agent_mode: str,
) -> dict[str, object] | None:
del thread_id
del thread_id, system_agent_mode
return {
"messages": [
{
@@ -307,13 +349,44 @@ async def test_build_recent_context_messages_skips_tool_without_metadata_output(
]
}
monkeypatch.setattr(
tasks_module, "get_agent_service", lambda session: _FakeAgentService()
)
monkeypatch.setattr(tasks_module, "AgentContextService", _FakeContextService)
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
)
assert messages == []
@pytest.mark.asyncio
async def test_build_recent_context_messages_passes_context_mode_through(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_mode: dict[str, str | None] = {"mode": None}
class _FakeContextService:
def __init__(self, *, repository: object) -> None:
del repository
async def load_context_messages(
self,
*,
thread_id: str,
system_agent_mode: str,
) -> dict[str, object] | None:
del thread_id
captured_mode["mode"] = system_agent_mode
return None
monkeypatch.setattr(tasks_module, "AgentContextService", _FakeContextService)
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
context_mode="worker",
)
assert messages == []
assert captured_mode["mode"] == "worker"