feat: 优化 Agent 运行时与聊天设置体验

This commit is contained in:
qzl
2026-03-16 18:32:09 +08:00
parent 3f79cf0df7
commit 5a34616287
41 changed files with 2603 additions and 1263 deletions
@@ -9,6 +9,7 @@ from core.agentscope.runtime.runner import (
StageExecutionResult,
SystemAgentRuntimeConfig,
)
from core.agentscope.utils import safe_json_loads_with_repair
from schemas.agent.runtime_models import (
RouterAgentOutput,
UiMode,
@@ -54,18 +55,7 @@ def _run_input() -> RunAgentInput:
"runId": "run-1",
"state": {},
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [
{
"name": "calendar.read",
"description": "read",
"parameters": {"type": "object"},
},
{
"name": "calendar-write",
"description": "write",
"parameters": {"type": "object"},
},
],
"tools": [],
"context": [],
"forwardedProps": {},
}
@@ -94,6 +84,30 @@ def _router_output(*, ui_mode: UiMode) -> RouterAgentOutput:
)
def test_build_worker_input_messages_includes_field_guide() -> None:
runner = AgentScopeRunner()
messages = runner._build_worker_input_messages(
router_output=_router_output(ui_mode=UiMode.NONE)
)
assert len(messages) == 1
content = str(messages[0].content)
assert "[Worker Contract]" in content
assert "Use normalized_task_input as objective text." in content
assert "multimodal_summary/key_entities/constraints" in content
assert "key_entities" in content
assert "constraints" in content
assert "Infer deterministic missing required tool args" in content
def test_safe_json_loads_with_repair_parses_valid_json() -> None:
parsed = safe_json_loads_with_repair('{"operation":"create","title":"test"}')
assert parsed["operation"] == "create"
assert parsed["title"] == "test"
@pytest.mark.asyncio
async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
monkeypatch: pytest.MonkeyPatch,
@@ -110,11 +124,6 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
"core.agentscope.runtime.runner.AsyncSessionLocal",
lambda: _FakeSessionCtx(_CommitSession()),
)
monkeypatch.setattr(
runner,
"_build_toolkits",
lambda **kwargs: ("router-toolkit", "worker-toolkit"),
)
async def _load_system_agent_config(**kwargs):
return SystemAgentRuntimeConfig(
@@ -147,7 +156,10 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
async def _persist_router_message(**kwargs) -> None:
assert kwargs["model_code"] == "qwen3.5-flash"
monkeypatch.setattr(runner, "_persist_router_message", _persist_router_message)
monkeypatch.setattr(
"core.agentscope.runtime.runner.persist_router_message",
_persist_router_message,
)
async def _run_worker_stage(**kwargs):
worker_model_holder["model"] = kwargs["worker_output_model"]
@@ -196,11 +208,3 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
]
assert result["router"]["ui"]["ui_mode"] == "rich"
assert result["worker"]["answer"] == "done"
def test_extract_tool_names_normalizes_client_tool_names() -> None:
runner = AgentScopeRunner()
names = runner._extract_tool_names(_run_input())
assert names == {"calendar_read", "calendar_write"}
@@ -174,3 +174,60 @@ async def test_run_agentscope_task_rejects_invalid_command_type() -> None:
"run_input": _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(
self,
*,
thread_id: str,
) -> dict[str, object] | None:
del thread_id
return {
"messages": [
{
"role": "user",
"content": "请看图片",
"metadata": {
"user_message_attachments": [
{
"bucket": "bucket-1",
"path": "path/a.png",
"mime_type": "image/png",
},
{
"bucket": "bucket-1",
"path": "path/b.jpg",
"mime_type": "image/jpeg",
},
]
},
}
]
}
class _FakeSupabase:
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, "supabase_service", _FakeSupabase())
messages = await tasks_module._build_recent_context_messages(
session=object(),
thread_id=str(uuid4()),
)
assert len(messages) == 1
content = messages[0].content
assert isinstance(content, list)
assert len(content) == 3
assert content[0]["type"] == "text"
assert content[1]["type"] == "image"
assert content[2]["type"] == "image"
@@ -5,7 +5,9 @@ from core.agentscope.prompts.agent_prompt import (
WORKER_AGENT_INSTRUCTION,
build_agent_prompt,
build_intent_user_prompt,
build_worker_contract_prompt,
)
from schemas.agent.runtime_models import RouterAgentOutput
from schemas.agent.system_agent import AgentType
@@ -39,6 +41,9 @@ def test_build_agent_prompt_for_worker_relies_on_injected_schema() -> None:
assert "- type: worker" in prompt
assert WORKER_AGENT_INSTRUCTION in prompt
assert "execute routed objective" in prompt
assert "objective/constraints contract" in prompt
assert "Infer deterministic required tool arguments" in prompt
assert "Ask minimal clarification" in prompt
assert "never fabricate execution state" in prompt
assert (
"The worker output schema is injected at runtime; follow it exactly." in prompt
@@ -46,3 +51,35 @@ def test_build_agent_prompt_for_worker_relies_on_injected_schema() -> None:
assert "Do not add fields that are not present in the injected schema." in prompt
assert "ui_mode=rich" not in prompt
assert "ui_mode=none" not in prompt
def test_build_worker_contract_prompt_is_concise_and_actionable() -> None:
router_output = RouterAgentOutput.model_validate(
{
"normalized_task_input": {
"user_text": "创建明天 9 点会议",
"multimodal_summary": ["图片里有会议时间"],
},
"key_entities": [
{
"name": "start",
"type": "datetime",
"value": "2026-03-17T09:00:00+08:00",
}
],
"constraints": [
{"key": "timezone", "value": "Asia/Shanghai", "required": True}
],
"task_typing": {"primary": "scheduling", "secondary": []},
"execution_mode": "tool_assisted",
"result_typing": {"primary": "execution_report", "secondary": []},
"ui": {"ui_mode": "none", "ui_decision_reason": "plain"},
}
)
prompt = build_worker_contract_prompt(router_output=router_output)
assert "[Worker Contract]" in prompt
assert "Infer deterministic missing required tool args" in prompt
assert "[RouterAgentOutput]" in prompt
assert '"execution_mode":"tool_assisted"' in prompt
+64 -10
View File
@@ -126,7 +126,10 @@ def _user() -> CurrentUser:
)
def _build_run_input(*, url: str) -> RunAgentInput:
def _build_run_input(*, urls: list[str]) -> RunAgentInput:
content: list[dict[str, str]] = [{"type": "text", "text": "hello"}]
for url in urls:
content.append({"type": "binary", "mimeType": "image/png", "url": url})
return RunAgentInput.model_validate(
{
"threadId": "00000000-0000-0000-0000-000000000001",
@@ -136,10 +139,7 @@ def _build_run_input(*, url: str) -> RunAgentInput:
{
"id": "u1",
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{"type": "binary", "mimeType": "image/png", "url": url},
],
"content": content,
}
],
"tools": [],
@@ -161,7 +161,9 @@ async def test_enqueue_run_rejects_non_project_host_signed_url(monkeypatch) -> N
attachment_storage=_FakeAttachmentStorage(),
)
run_input = _build_run_input(
url="https://evil.example.com/storage/v1/object/sign/agent-test-bucket/a.png?token=1"
urls=[
"https://evil.example.com/storage/v1/object/sign/agent-test-bucket/a.png?token=1"
]
)
with pytest.raises(HTTPException) as exc_info:
@@ -191,8 +193,15 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token(
"agent-inputs/00000000-0000-0000-0000-000000000001/"
"00000000-0000-0000-0000-000000000001/uploads/a.png"
)
safe_path_two = quote(
"agent-inputs/00000000-0000-0000-0000-000000000001/"
"00000000-0000-0000-0000-000000000001/uploads/b.png"
)
run_input = _build_run_input(
url=f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1"
urls=[
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1",
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path_two}?token=1",
]
)
accepted = await service.enqueue_run(run_input=run_input, current_user=_user())
@@ -201,9 +210,10 @@ async def test_enqueue_run_persists_attachment_and_queue_without_user_token(
persisted = repository.persisted_user_messages[0]
metadata = cast(AgentChatMessageMetadata | None, persisted["metadata"])
assert metadata is not None
attachment = metadata.user_message_attachments
assert attachment is not None
assert attachment.bucket == "agent-test-bucket"
attachments = metadata.user_message_attachments
assert attachments is not None
assert len(attachments) == 2
assert attachments[0].bucket == "agent-test-bucket"
command = queue.commands[0]
assert "user_token" not in command
run_input = command["run_input"]
@@ -257,3 +267,47 @@ async def test_create_attachment_signed_url_rejects_out_of_scope_path(
)
assert exc_info.value.status_code == 422
@pytest.mark.asyncio
async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None:
monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket"
)
service = AgentService(
repository=_FakeRepository(),
queue=_FakeQueue(),
stream=_FakeStream(),
attachment_storage=_FakeAttachmentStorage(),
)
base_url = str(config.supabase.url).rstrip("/")
safe_paths = [
quote(
"agent-inputs/00000000-0000-0000-0000-000000000001/"
"00000000-0000-0000-0000-000000000001/uploads/a.png"
),
quote(
"agent-inputs/00000000-0000-0000-0000-000000000001/"
"00000000-0000-0000-0000-000000000001/uploads/b.png"
),
quote(
"agent-inputs/00000000-0000-0000-0000-000000000001/"
"00000000-0000-0000-0000-000000000001/uploads/c.png"
),
quote(
"agent-inputs/00000000-0000-0000-0000-000000000001/"
"00000000-0000-0000-0000-000000000001/uploads/d.png"
),
]
run_input = _build_run_input(
urls=[
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1"
for safe_path in safe_paths
]
)
with pytest.raises(HTTPException) as exc_info:
await service.enqueue_run(run_input=run_input, current_user=_user())
assert exc_info.value.status_code == 422
assert exc_info.value.detail == "Too many attachments"
+23
View File
@@ -48,3 +48,26 @@ def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() -
assert "ui_schema" in result
assert "uiSchema" not in result
assert result["ui_schema"] == {"version": "2.0", "root": {"type": "stack"}}
def test_convert_message_to_history_returns_multiple_user_attachments() -> None:
message = _FakeMessage(
role="user",
metadata={
"user_message_attachments": [
{"bucket": "bucket-a", "path": "path/a.png", "mime_type": "image/png"},
{"bucket": "bucket-a", "path": "path/b.jpg", "mime_type": "image/jpeg"},
]
},
)
def _signed(payload: dict[str, str]) -> str:
return f"https://signed.example/{payload['bucket']}/{payload['path']}"
result = convert_message_to_history(message, _signed) # type: ignore[arg-type]
attachments = result.get("attachments")
assert isinstance(attachments, list)
assert len(attachments) == 2
assert attachments[0]["mimeType"] == "image/png"
assert attachments[1]["mimeType"] == "image/jpeg"